UNPKG

@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
"use strict"; 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