@pimzino/claude-code-spec-workflow
Version:
Automated workflows for Claude Code. Includes spec-driven development (Requirements → Design → Tasks → Implementation) with intelligent task execution, optional steering documents and streamlined bug fix workflow (Report → Analyze → Fix → Verify). We have
796 lines • 37.3 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.MultiProjectDashboardServer = void 0;
const fastify_1 = __importDefault(require("fastify"));
const static_1 = __importDefault(require("@fastify/static"));
const websocket_1 = __importDefault(require("@fastify/websocket"));
const path_1 = require("path");
const promises_1 = require("fs/promises");
const watcher_1 = require("./watcher");
const parser_1 = require("./parser");
const project_discovery_1 = require("./project-discovery");
const open_1 = __importDefault(require("open"));
const os_1 = require("os");
const utils_1 = require("../utils");
const logger_1 = require("./logger");
const tunnel_1 = require("./tunnel");
const cloudflare_provider_native_1 = require("./tunnel/cloudflare-provider-native");
const ngrok_provider_native_1 = require("./tunnel/ngrok-provider-native");
class MultiProjectDashboardServer {
constructor(options) {
this.clients = new Set();
this.projects = new Map();
this.options = options;
this.discovery = new project_discovery_1.ProjectDiscovery();
this.app = (0, fastify_1.default)({ logger: false });
}
async start() {
// Discover projects
console.log('Starting project discovery...');
const discoveredProjects = await this.discovery.discoverProjects();
// Projects are already filtered by discovery
console.log(`Found ${discoveredProjects.length} projects:`);
discoveredProjects.forEach(p => {
console.log(` - ${p.name} at ${p.path} (specs: ${p.specCount}, bugs: ${p.bugCount}, active: ${p.hasActiveSession})`);
});
// Initialize watchers for each project
for (const project of discoveredProjects) {
(0, logger_1.debug)(`Initializing project ${project.name} at ${project.path}`);
await this.initializeProject(project);
(0, logger_1.debug)(`Project ${project.name} initialized and added to projects Map`);
}
// Log all projects in the Map
(0, logger_1.debug)(`Total projects in Map: ${this.projects.size}`);
this.projects.forEach((state, path) => {
(0, logger_1.debug)(` - ${state.project.name}: ${path}`);
});
await this.app.register(websocket_1.default);
// Register static file serving FIRST (before other routes)
// In development, files are in src/dashboard, in production they're in dist/dashboard
// We need to handle app.js specially since it might be in dist/dashboard even in dev mode
await this.app.register(static_1.default, {
root: __dirname, // Serve from the current directory (either src/dashboard or dist/dashboard)
prefix: '/',
decorateReply: true, // Enable sendFile method on reply
wildcard: false // Disable wildcard to prevent catching all routes
});
// WebSocket endpoint
const self = this;
this.app.register(async function (fastify) {
fastify.get('/ws', { websocket: true }, (connection) => {
const socket = connection.socket;
(0, logger_1.debug)('Multi-project WebSocket client connected');
self.clients.add(socket);
// Send initial state with all projects
self.sendInitialState(socket);
socket.on('close', () => {
self.clients.delete(socket);
});
socket.on('error', (error) => {
console.error('WebSocket error:', error);
self.clients.delete(socket);
});
});
});
// API endpoints
this.app.get('/api/projects', async () => {
const projectList = Array.from(this.projects.values()).map((p) => ({
...p.project,
specs: [], // Don't include specs in list
}));
return projectList;
});
this.app.get('/api/projects/:projectPath/specs', async (request, reply) => {
const { projectPath } = request.params;
const decodedPath = (0, path_1.normalize)((0, path_1.resolve)(decodeURIComponent(projectPath)));
const projectState = this.projects.get(decodedPath);
if (!projectState) {
reply.code(404).send({ error: 'Project not found' });
return;
}
const specs = await projectState.parser.getAllSpecs();
return specs;
});
this.app.get('/api/projects/:projectPath/bugs', async (request, reply) => {
const { projectPath } = request.params;
const decodedPath = (0, path_1.normalize)((0, path_1.resolve)(decodeURIComponent(projectPath)));
const projectState = this.projects.get(decodedPath);
if (!projectState) {
reply.code(404).send({ error: 'Project not found' });
return;
}
const bugs = await projectState.parser.getAllBugs();
return bugs;
});
// Get raw markdown content for a specific document
this.app.get('/api/projects/:projectPath/specs/:name/:document', async (request, reply) => {
const { projectPath, name, document } = request.params;
const decodedPath = (0, path_1.normalize)((0, path_1.resolve)(decodeURIComponent(projectPath)));
const projectState = this.projects.get(decodedPath);
if (!projectState) {
reply.code(404).send({ error: 'Project not found' });
return;
}
const allowedDocs = ['requirements', 'design', 'tasks'];
if (!allowedDocs.includes(document)) {
reply.code(400).send({ error: 'Invalid document type' });
return;
}
const docPath = (0, path_1.join)(decodedPath, '.claude', 'specs', name, `${document}.md`);
try {
const content = await (0, promises_1.readFile)(docPath, 'utf-8');
return { content };
}
catch {
reply.code(404).send({ error: 'Document not found' });
}
});
// Get raw markdown content for bug documents
this.app.get('/api/projects/:projectPath/bugs/:name/:document', async (request, reply) => {
const { projectPath, name, document } = request.params;
const decodedPath = (0, path_1.normalize)((0, path_1.resolve)(decodeURIComponent(projectPath)));
const projectState = this.projects.get(decodedPath);
if (!projectState) {
reply.code(404).send({ error: 'Project not found' });
return;
}
const allowedDocs = ['report', 'analysis', 'fix', 'verification'];
if (!allowedDocs.includes(document)) {
reply.code(400).send({ error: 'Invalid document type' });
return;
}
const docPath = (0, path_1.join)(decodedPath, '.claude', 'bugs', name, `${document}.md`);
try {
const content = await (0, promises_1.readFile)(docPath, 'utf-8');
return { content };
}
catch {
reply.code(404).send({ error: 'Document not found' });
}
});
// Tunnel API endpoints
this.app.get('/api/tunnel/status', async () => {
const status = this.getTunnelStatus();
return status;
});
this.app.post('/api/tunnel/start', async (request, reply) => {
try {
// Always ensure tunnel manager is initialized
if (!this.tunnelManager) {
this.tunnelManager = new tunnel_1.TunnelManager(this.app);
this.tunnelManager.registerProvider(new cloudflare_provider_native_1.CloudflareProvider());
this.tunnelManager.registerProvider(new ngrok_provider_native_1.NgrokProvider());
// Listen for tunnel events
this.tunnelManager.on('tunnel:started', (tunnelInfo) => {
console.log(`✅ Tunnel started: ${tunnelInfo.url} (${tunnelInfo.provider})`);
(0, logger_1.debug)('Tunnel started event:', tunnelInfo);
this.broadcast({
type: 'tunnel:started',
data: tunnelInfo
});
});
this.tunnelManager.on('tunnel:stopped', (data) => {
console.log('🛑 Tunnel stopped');
(0, logger_1.debug)('Tunnel stopped event:', data);
this.broadcast({
type: 'tunnel:stopped',
data: data || {}
});
});
this.tunnelManager.on('tunnel:metrics:updated', (metrics) => {
(0, logger_1.debug)('Tunnel metrics updated:', metrics);
this.broadcast({
type: 'tunnel:metrics:updated',
data: metrics
});
});
this.tunnelManager.on('tunnel:visitor:new', (visitor) => {
console.log(`👤 New tunnel visitor from ${visitor.country || 'Unknown'}`);
(0, logger_1.debug)('New tunnel visitor:', visitor);
});
this.tunnelManager.on('tunnel:recovery:start', (data) => {
console.log(`🔄 Tunnel recovery started (attempt ${data.attempt})`);
});
this.tunnelManager.on('tunnel:recovery:success', () => {
console.log('✅ Tunnel recovery successful');
});
this.tunnelManager.on('tunnel:recovery:failed', (data) => {
console.log(`❌ Tunnel recovery failed: ${data.error}`);
});
}
// Check if tunnel is already active
const status = this.getTunnelStatus();
if (status.active) {
return { success: true, message: 'Tunnel already active', tunnelInfo: status.info };
}
// Start tunnel with options from command line
const tunnelOptions = {
provider: this.options.tunnelProvider,
password: this.options.tunnelPassword,
analytics: true
};
console.log(`🚀 Starting tunnel (provider: ${tunnelOptions.provider || 'auto'})...`);
(0, logger_1.debug)(`Starting tunnel with provider: ${tunnelOptions.provider || 'auto'}`);
const tunnelInfo = await this.tunnelManager.startTunnel(tunnelOptions);
(0, logger_1.debug)('Tunnel started via API:', tunnelInfo);
return { success: true, tunnelInfo };
}
catch (error) {
console.error('Error starting tunnel:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
reply.code(500).send({ error: 'Failed to start tunnel', details: errorMessage });
}
});
this.app.post('/api/tunnel/stop', async (request, reply) => {
if (!this.tunnelManager) {
reply.code(404).send({ error: 'No tunnel manager available' });
return;
}
try {
await this.tunnelManager.stopTunnel();
// Broadcast tunnel stopped event
this.broadcast({
type: 'tunnel:stopped',
data: {}
});
return { success: true };
}
catch (error) {
console.error('Error stopping tunnel:', error);
reply.code(500).send({ error: 'Failed to stop tunnel' });
}
});
// SPA fallback - serve index.html for any non-API, non-static route
// This must come AFTER all API routes but will work with the static plugin
this.app.setNotFoundHandler((request, reply) => {
// Only handle GET requests that aren't API or WebSocket
if (request.method === 'GET' &&
!request.url.startsWith('/api/') &&
!request.url.startsWith('/ws')) {
// Serve the index.html file for SPA routing
reply.sendFile('index.html');
}
else {
reply.code(404).send({ error: 'Not found' });
}
});
// Find available port if the requested port is busy
let actualPort = this.options.port;
if (!(await (0, utils_1.isPortAvailable)(this.options.port))) {
console.log(`Port ${this.options.port} is in use, finding alternative...`);
actualPort = await (0, utils_1.findAvailablePort)(this.options.port);
console.log(`Using port ${actualPort} instead`);
}
// Start server
await this.app.listen({ port: actualPort, host: '0.0.0.0' });
// Update the port in options for URL generation
this.options.port = actualPort;
// Initialize tunnel if requested
if (this.options.tunnel) {
await this.initializeTunnel();
}
// Start periodic rescan for new active projects and cleanup removed ones
// This complements file watching by detecting removed projects
this.startPeriodicRescan();
// Open browser if requested (always use localhost for local user)
if (this.options.autoOpen) {
await (0, open_1.default)(`http://localhost:${this.options.port}`);
}
}
async initializeProject(project) {
// Normalize and resolve the project path to handle different path formats
const normalizedPath = (0, path_1.normalize)((0, path_1.resolve)(project.path));
project.path = normalizedPath; // Update the project path with normalized version
const parser = new parser_1.SpecParser(normalizedPath);
const watcher = new watcher_1.SpecWatcher(normalizedPath, parser);
// Set up watcher events
watcher.on('change', async (event) => {
(0, logger_1.debug)(`[Multi-server] Watcher change event received for project ${project.name}:`, event);
// Transform the watcher event into the format expected by the client
const projectUpdateEvent = {
type: 'spec-update',
spec: event.spec,
file: event.file,
data: event.data,
};
// Broadcast update with project context
const message = JSON.stringify({
type: 'project-update',
projectPath: project.path,
data: projectUpdateEvent,
});
(0, logger_1.debug)(`[Multi-server] Sending spec update for ${project.name}/${event.spec} to ${this.clients.size} clients`);
let sentCount = 0;
this.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(message);
sentCount++;
(0, logger_1.debug)(`[Multi-server] Sent update to client ${sentCount}`);
}
else {
(0, logger_1.debug)(`[Multi-server] Client has readyState ${client.readyState}, skipping`);
}
});
if (sentCount === 0) {
(0, logger_1.debug)(`[Multi-server] WARNING: No clients received the update!`);
}
// Also send updated active sessions
const activeSessions = await this.collectActiveSessions();
(0, logger_1.debug)(`[Multi-server] Collected ${activeSessions.length} active sessions after spec update`);
const activeSessionsMessage = JSON.stringify({
type: 'active-sessions-update',
data: activeSessions,
});
this.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(activeSessionsMessage);
}
});
});
// Handle git change events
watcher.on('git-change', async (event) => {
(0, logger_1.debug)(`[Multi-server] Git change detected for ${project.name}:`, event);
// Update the project's git info
if (event.branch) {
project.gitBranch = event.branch;
}
if (event.commit) {
project.gitCommit = event.commit;
}
// Send git update to all clients
const message = JSON.stringify({
type: 'git-update',
projectPath: project.path,
gitBranch: project.gitBranch,
gitCommit: project.gitCommit,
});
this.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(message);
}
});
});
// Handle steering change events
watcher.on('steering-change', async (event) => {
(0, logger_1.debug)(`[Multi-server] Steering change detected for ${project.name}:`, event);
// Send steering update to all clients
const message = JSON.stringify({
type: 'steering-update',
projectPath: project.path,
data: event.steeringStatus,
});
this.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(message);
}
});
});
// Handle bug change events
watcher.on('bug-change', async (event) => {
(0, logger_1.debug)(`[Multi-server] Bug change detected for ${project.name}:`, event);
// Send bug update to all clients
const message = JSON.stringify({
type: 'bug-update',
projectPath: project.path,
data: event,
});
this.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(message);
}
});
});
(0, logger_1.debug)(`[Multi-server] Starting watcher for project ${project.name} at path: ${project.path}`);
await watcher.start();
(0, logger_1.debug)(`[Multi-server] Watcher started successfully for ${project.name}`);
this.projects.set(project.path, {
project,
parser,
watcher,
});
}
async sendInitialState(socket) {
const projects = await Promise.all(Array.from(this.projects.entries()).map(async ([path, state]) => {
const specs = await state.parser.getAllSpecs();
const bugs = await state.parser.getAllBugs();
const steeringStatus = await state.parser.getProjectSteeringStatus();
const projectData = {
...state.project,
specs,
bugs,
steering: steeringStatus,
};
(0, logger_1.debug)(`Sending project ${projectData.name} (${path}) with ${specs.length} specs, ${bugs.length} bugs`);
return projectData;
}));
// Collect all active sessions across projects
const activeSessions = await this.collectActiveSessions();
(0, logger_1.debug)(`Sending initial state: ${projects.length} projects, ${activeSessions.length} active sessions`);
projects.forEach(p => {
(0, logger_1.debug)(` Project: ${p.name} (${p.path}) - specs: ${p.specs?.length || 0}, bugs: ${p.bugs?.length || 0}`);
});
socket.send(JSON.stringify({
type: 'initial',
data: projects,
activeSessions,
username: this.getUsername(),
}));
}
async collectActiveSessions() {
const allActiveSessions = [];
for (const [projectPath, state] of this.projects) {
// Only include projects where a Claude process is actually running
if (!state.project.hasActiveSession) {
(0, logger_1.debug)(`[collectActiveSessions] Project ${state.project.name}: no active Claude process, skipping`);
continue;
}
(0, logger_1.debug)(`[collectActiveSessions] Project ${state.project.name}: has active Claude process, finding active work item`);
const specs = await state.parser.getAllSpecs();
const bugs = await state.parser.getAllBugs();
const activeWorkItems = [];
// Collect specs with active tasks (highest priority)
for (const spec of specs) {
if (spec.tasks && spec.tasks.inProgress) {
activeWorkItems.push({
type: 'spec',
item: spec,
lastModified: spec.lastModified || new Date(0),
priority: 100 // Highest priority - active task
});
}
}
// Collect active bugs (high priority)
for (const bug of bugs) {
if (['analyzing', 'fixing', 'verifying'].includes(bug.status)) {
activeWorkItems.push({
type: 'bug',
item: bug,
lastModified: bug.lastModified || new Date(0),
priority: 90 // High priority - active bug
});
}
}
// If no active work, collect recent incomplete work as fallback (lower priority)
if (activeWorkItems.length === 0) {
// Add most recently modified specs that are not completed
for (const spec of specs) {
if (spec.status !== 'completed') {
activeWorkItems.push({
type: 'spec',
item: spec,
lastModified: spec.lastModified || new Date(0),
priority: 10 // Low priority - incomplete work
});
}
}
// Add most recently modified bugs that are not resolved
for (const bug of bugs) {
if (bug.status !== 'resolved') {
activeWorkItems.push({
type: 'bug',
item: bug,
lastModified: bug.lastModified || new Date(0),
priority: 10 // Low priority - incomplete work
});
}
}
}
// Sort by priority first, then by lastModified descending
activeWorkItems.sort((a, b) => {
if (a.priority !== b.priority) {
return b.priority - a.priority; // Higher priority first
}
return b.lastModified.getTime() - a.lastModified.getTime(); // Most recent first
});
let activeSession = null;
if (activeWorkItems.length > 0) {
const mostRecent = activeWorkItems[0];
if (mostRecent.type === 'spec') {
const spec = mostRecent.item;
// For specs, try to find an active task, otherwise use a placeholder
let activeTask = null;
if (spec.tasks && spec.tasks.inProgress) {
activeTask = this.findTaskById(spec.tasks.taskList, spec.tasks.inProgress);
}
// If no active task, create a placeholder representing the spec
if (!activeTask) {
activeTask = {
id: 'session',
description: `${spec.displayName || spec.name}`,
completed: false,
requirements: []
};
}
activeSession = {
type: 'spec',
projectPath,
projectName: state.project.name,
displayName: spec.displayName || spec.name,
specName: spec.name,
status: spec.status, // Include spec status
requirements: spec.requirements,
design: spec.design,
tasks: spec.tasks,
task: activeTask,
lastModified: mostRecent.lastModified,
isCurrentlyActive: true,
hasActiveSession: true,
gitBranch: state.project.gitBranch,
gitCommit: state.project.gitCommit,
};
}
else if (mostRecent.type === 'bug') {
const bug = mostRecent.item;
// Determine the next command based on bug status
let nextCommand = '';
switch (bug.status) {
case 'reported':
nextCommand = `/bug-analyze ${bug.name}`;
break;
case 'analyzing':
nextCommand = `/bug-analyze ${bug.name}`;
break;
case 'fixing':
nextCommand = `/bug-fix ${bug.name}`;
break;
case 'verifying':
nextCommand = `/bug-verify ${bug.name}`;
break;
case 'fixed':
nextCommand = `/bug-verify ${bug.name}`;
break;
case 'resolved':
nextCommand = ''; // Bug is complete, no next command
break;
default:
nextCommand = `/bug-analyze ${bug.name}`;
}
activeSession = {
type: 'bug',
projectPath,
projectName: state.project.name,
displayName: bug.displayName || bug.name,
bugName: bug.name,
bugStatus: bug.status, // Use the actual bug status, including 'fixed' and 'resolved'
bugSeverity: bug.report?.severity,
nextCommand,
lastModified: mostRecent.lastModified,
isCurrentlyActive: true,
hasActiveSession: true,
gitBranch: state.project.gitBranch,
gitCommit: state.project.gitCommit,
};
}
}
else {
// No work items found, create a generic session for the active Claude process
activeSession = {
type: 'spec',
projectPath,
projectName: state.project.name,
displayName: 'Ad-hoc Session',
specName: 'ad-hoc-session',
isAdHoc: true, // Mark as ad-hoc
task: {
id: 'session',
description: 'Active Claude session',
completed: false,
requirements: []
},
lastModified: new Date(),
isCurrentlyActive: true,
hasActiveSession: true,
gitBranch: state.project.gitBranch,
gitCommit: state.project.gitCommit,
};
}
if (activeSession) {
(0, logger_1.debug)(`[collectActiveSessions] Project ${state.project.name}: selected ${activeSession.type} session (${activeSession.type === 'spec' ? activeSession.specName : activeSession.bugName})`);
allActiveSessions.push(activeSession);
}
}
// Sort by currently active first, then by project name
allActiveSessions.sort((a, b) => {
if (a.isCurrentlyActive && !b.isCurrentlyActive)
return -1;
if (!a.isCurrentlyActive && b.isCurrentlyActive)
return 1;
return a.projectName.localeCompare(b.projectName);
});
(0, logger_1.debug)(`[collectActiveSessions] Total active sessions found: ${allActiveSessions.length} (filtered to single session per project)`);
return allActiveSessions;
}
findTaskById(tasks, taskId) {
for (const task of tasks) {
if (task.id === taskId) {
return task;
}
if (task.subtasks) {
const found = this.findTaskById(task.subtasks, taskId);
if (found) {
return found;
}
}
}
return null;
}
startPeriodicRescan() {
// Rescan every 10 seconds for new/removed projects
this.rescanInterval = setInterval(async () => {
const activeProjects = await this.discovery.discoverProjects();
// Check for new projects
for (const project of activeProjects) {
if (!this.projects.has(project.path)) {
(0, logger_1.debug)(`New active project detected: ${project.name}`);
await this.initializeProject(project);
// Notify all clients about the new project
const parser = this.projects.get(project.path)?.parser;
const specs = (await parser?.getAllSpecs()) || [];
const bugs = (await parser?.getAllBugs()) || [];
const steeringStatus = await parser?.getProjectSteeringStatus();
const projectData = { ...project, specs, bugs, steering: steeringStatus };
const message = JSON.stringify({
type: 'new-project',
data: projectData,
});
this.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(message);
}
});
}
}
// Check for projects that should be removed
// A project should be removed if:
// 1. It's not in the discovered projects list (no .claude directory or no specs/bugs)
// 2. It has no active Claude session
for (const [path, state] of this.projects) {
const stillExists = activeProjects.some((p) => p.path === path);
if (!stillExists) {
(0, logger_1.debug)(`Project no longer exists or has no content: ${state.project.name}`);
// Stop the watcher for this project
await state.watcher.stop();
this.projects.delete(path);
// Notify clients to remove the project
const message = JSON.stringify({
type: 'remove-project',
data: { path },
});
this.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(message);
}
});
(0, logger_1.debug)(`Removed project: ${state.project.name} from dashboard`);
}
}
// Also send updated active sessions periodically
const activeSessions = await this.collectActiveSessions();
const activeSessionsMessage = JSON.stringify({
type: 'active-sessions-update',
data: activeSessions,
});
this.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(activeSessionsMessage);
}
});
}, 10000); // 10 seconds for more responsive updates
}
async stop() {
// Stop the rescan interval
if (this.rescanInterval) {
clearInterval(this.rescanInterval);
}
// Stop all watchers
for (const [, state] of this.projects) {
await state.watcher.stop();
}
// Stop the tunnel if active
if (this.tunnelManager) {
await this.tunnelManager.stopTunnel();
}
// Close all WebSocket connections
this.clients.forEach((client) => {
if (client.readyState === 1) {
client.close();
}
});
this.clients.clear();
// Close the server
await this.app.close();
}
async initializeTunnel() {
// Initialize tunnel manager
this.tunnelManager = new tunnel_1.TunnelManager(this.app);
// Register providers
this.tunnelManager.registerProvider(new cloudflare_provider_native_1.CloudflareProvider());
this.tunnelManager.registerProvider(new ngrok_provider_native_1.NgrokProvider());
// Listen for tunnel events
this.tunnelManager.on('tunnel:started', (tunnelInfo) => {
console.log(`✅ Tunnel started: ${tunnelInfo.url} (${tunnelInfo.provider})`);
(0, logger_1.debug)('Tunnel started event:', tunnelInfo);
this.broadcast({
type: 'tunnel:started',
data: tunnelInfo
});
});
this.tunnelManager.on('tunnel:stopped', (data) => {
console.log('🛑 Tunnel stopped');
(0, logger_1.debug)('Tunnel stopped event:', data);
this.broadcast({
type: 'tunnel:stopped',
data: data || {}
});
});
this.tunnelManager.on('tunnel:metrics:updated', (metrics) => {
(0, logger_1.debug)('Tunnel metrics updated:', metrics);
this.broadcast({
type: 'tunnel:metrics:updated',
data: metrics
});
});
this.tunnelManager.on('tunnel:visitor:new', (visitor) => {
console.log(`👤 New tunnel visitor from ${visitor.country || 'Unknown'}`);
(0, logger_1.debug)('New tunnel visitor:', visitor);
});
this.tunnelManager.on('tunnel:recovery:start', (data) => {
console.log(`🔄 Tunnel recovery started (attempt ${data.attempt})`);
});
this.tunnelManager.on('tunnel:recovery:success', () => {
console.log('✅ Tunnel recovery successful');
});
this.tunnelManager.on('tunnel:recovery:failed', (data) => {
console.log(`❌ Tunnel recovery failed: ${data.error}`);
});
// Start tunnel with options
const tunnelOptions = {
provider: this.options.tunnelProvider,
password: this.options.tunnelPassword,
analytics: true
};
try {
console.log(`🚀 Starting tunnel (provider: ${tunnelOptions.provider || 'auto'})...`);
const tunnelInfo = await this.tunnelManager.startTunnel(tunnelOptions);
(0, logger_1.debug)('Tunnel created:', tunnelInfo);
}
catch (error) {
if (error instanceof tunnel_1.TunnelProviderError) {
console.error('Tunnel setup failed:', error.getUserFriendlyMessage());
}
else {
console.error('Failed to create tunnel:', error);
}
throw error;
}
}
getTunnelStatus() {
return this.tunnelManager?.getStatus() || { active: false };
}
broadcast(message) {
const jsonMessage = JSON.stringify(message);
this.clients.forEach((client) => {
if (client.readyState === 1) {
// WebSocket.OPEN
client.send(jsonMessage);
}
});
}
getUsername() {
try {
const info = (0, os_1.userInfo)();
// Try to get the full name first, fall back to username
const username = info.username || 'User';
// Capitalize first letter
return username.charAt(0).toUpperCase() + username.slice(1);
}
catch {
return 'User';
}
}
}
exports.MultiProjectDashboardServer = MultiProjectDashboardServer;
//# sourceMappingURL=multi-server.js.map