Skip to content

Conversation

@TabishB
Copy link
Contributor

@TabishB TabishB commented Jan 28, 2026

Summary

Implements a new openspec dashboard command that serves a local HTTP server with a web-based dashboard for exploring OpenSpec changes, specifications, and archive history. The dashboard provides an interactive alternative to the terminal-based view command with clickable artifacts and detailed views.

Key features: Three-tab navigation (Changes/Specs/Archive), rendered markdown artifact viewing, domain-grouped specs, task progress tracking, artifact status indicators, archive pagination, and path traversal protection. Zero external dependencies beyond what OpenSpec already uses.

Test Coverage

Added 43 comprehensive unit tests covering markdown rendering, data gathering, domain grouping, archive date parsing, and API security (path traversal prevention). All 1233 tests in the test suite pass.

Implementation Details

  • Uses Node.js built-in http module (no Express/Fastify dependency)
  • Embedded SPA HTML with dark terminal-inspired theme
  • Port auto-selection (3000–3010) with --port override and --no-open flag
  • Cross-platform browser opening (macOS/Linux/Windows)

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added a dashboard command to launch a local web-based dashboard for viewing project changes, specifications, and archive entries
    • Dashboard supports --port option for custom port selection and --no-open flag to disable automatic browser launch
  • Chores

    • Added deprecation notice for the change command group, recommending verb-first commands instead

✏️ Tip: You can customize this high-level summary in your review settings.

Implements a new `openspec dashboard` command that serves a local HTTP server with a web-based dashboard for exploring changes, specs, and archive. Features include:
- Three-tab navigation for Changes, Specifications, and Archive
- Click artifacts to view rendered markdown in a detail panel
- Domain-grouped specs with requirement counts
- Task progress tracking for active changes
- Artifact status indicators (proposal, specs, design, tasks)
- Archive pagination with reverse chronological sorting
- Zero external dependencies (Node.js built-in http module)
- Port auto-increment (3000-3010) with --port override
- Cross-platform browser opening (macOS, Linux, Windows)
- Path traversal prevention on artifact API

Includes comprehensive tests for markdown renderer, data gathering, and API security (43 tests, all passing).
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 28, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a complete dashboard feature for OpenSpec. It adds a CLI command that starts a local HTTP server exposing REST API endpoints for project data (changes, specifications, archive). The implementation includes a data gathering layer, a markdown renderer for artifact display, a server with embedded HTML frontend, comprehensive test coverage, and a project blueprint document.

Changes

Cohort / File(s) Summary
Dashboard CLI Integration
src/cli/index.ts
Added new top-level "dashboard" CLI command with --port and --no-open options; instantiates DashboardCommand with error handling; introduced deprecation warning for change command group.
Dashboard Data Layer
src/core/dashboard/data.ts
New data module with type definitions (ChangeEntry, SpecGroup, ArchiveEntry, etc.) and functions: getChangesData (scans changes/, derives status from task progress), getSpecsData (parses specs/, groups by domain), getArchiveData (with pagination), getSummary (aggregates counts), getArtifactContent (retrieves artifact content with path traversal protection and markdown rendering).
Dashboard Core Components
src/core/dashboard/index.ts, src/core/dashboard/markdown.ts
DashboardCommand class handles port auto-increment (3000–3010), browser launching, graceful shutdown on SIGINT/SIGTERM; renderMarkdown function converts markdown subset (headings, lists, code blocks, links, emphasis, blockquotes) to HTML with inline rendering pipeline.
Dashboard Server & Frontend
src/core/dashboard/server.ts
HTTP server implementation with embedded HTML dashboard UI; API routes: /api/summary, /api/changes, /api/specs, /api/archive?limit=&offset=, /api/artifact?path=; utilities for port discovery (findAvailablePort), port availability checking, and cross-platform browser launching.
Dashboard Test Suite
test/core/dashboard/data.test.ts, test/core/dashboard/markdown.test.ts, test/core/dashboard/server.test.ts
Comprehensive tests covering: data gathering (changes, specs, archive categorization and sorting), markdown rendering (headings, lists, code, emphasis, blockquotes, links), server endpoints and API responses, path traversal security, pagination, port selection, HTML artifact rendering.
Project Blueprint
openspec/changes/add-dashboard-command/tasks.md
Detailed specification document outlining dashboard architecture, folder structure, CLI integration, data modules, markdown renderer, HTTP server design, frontend components, test scenarios, and polish steps.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CLI as CLI / Browser
    participant Server as Dashboard Server
    participant DataModule as Data Module
    participant FileSystem as File System

    User->>CLI: openspec dashboard
    CLI->>Server: startServer(port, openspecDir)
    Server->>Server: Listen on port

    User->>CLI: Open browser → localhost:port
    CLI->>Server: GET /

    Server->>Server: Serve HTML dashboard
    Server-->>CLI: HTML with embedded client

    CLI->>Server: GET /api/summary
    Server->>DataModule: getSummary(openspecDir)
    DataModule->>FileSystem: Scan changes/, specs/, archive/
    FileSystem-->>DataModule: Directory contents
    DataModule->>DataModule: Parse & aggregate counts
    DataModule-->>Server: DashboardSummary
    Server-->>CLI: JSON response

    User->>CLI: Click artifact link
    CLI->>Server: GET /api/artifact?path=changes/foo/proposal.md

    Server->>DataModule: getArtifactContent(openspecDir, path)
    DataModule->>FileSystem: Validate & read file
    FileSystem-->>DataModule: File content
    DataModule->>DataModule: Render markdown → HTML
    DataModule-->>Server: { html: "..." }
    Server-->>CLI: HTML fragment

    CLI->>CLI: Render artifact in detail pane
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 A shiny dashboard hops into view,
With changes and specs in a terminal brew,
From CLI to server, the port numbers dance,
Path guards stand watch—no traversal by chance!
Markdown blooms bright on the front-end display,
A dashboard that shows what's building today! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main feature being added: a dashboard command for web-based browsing of OpenSpec. It accurately reflects the changeset's primary purpose.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link

greptile-apps bot commented Jan 28, 2026

Greptile Overview

Greptile Summary

Implements openspec dashboard command that serves a local web-based interface for browsing OpenSpec projects, providing an interactive alternative to the terminal-based view command.

Key implementation highlights:

  • Uses Node.js built-in http module for zero external dependencies
  • Embedded single-page application with dark terminal-inspired theme
  • Three-tab navigation: Changes (with task progress), Specifications (domain-grouped), and Archive (paginated)
  • Clickable artifacts that render markdown in a side panel
  • Path traversal protection and file type restrictions (.md, .yaml only)
  • Port auto-selection (3000-3010) with manual override via --port
  • Cross-platform browser opening with fallback message
  • 43 comprehensive unit tests with excellent coverage

Minor issue found:

  • Race condition in src/core/dashboard/index.ts:34-41 where error handler is attached after server.listen() is called, which could cause unhandled errors if port binding fails immediately

Confidence Score: 4/5

  • Safe to merge after fixing the race condition in server error handling
  • Score of 4 reflects high-quality implementation with excellent test coverage (43 tests), proper security measures (path traversal protection), and clean architecture. The race condition in error handling is the only blocking issue that should be fixed before merging.
  • src/core/dashboard/index.ts requires fixing the race condition where error handler must be attached before server.listen() is called

Important Files Changed

Filename Overview
src/core/dashboard/index.ts Main command handler with port selection logic and browser opening
src/core/dashboard/data.ts Data gathering module with comprehensive path traversal protection
src/core/dashboard/server.ts HTTP server with embedded SPA and API endpoints for dashboard data

Sequence Diagram

sequenceDiagram
    participant User
    participant CLI as CLI (index.ts)
    participant Dashboard as DashboardCommand
    participant Server as HTTP Server
    participant Data as Data Module
    participant Browser
    participant Client as Web Client

    User->>CLI: openspec dashboard [--port N] [--no-open]
    CLI->>Dashboard: execute(path, options)
    Dashboard->>Dashboard: validate openspec dir exists
    
    alt explicit port provided
        Dashboard->>Dashboard: use requested port
    else auto-select port
        Dashboard->>Server: findAvailablePort(3000-3010)
        Server-->>Dashboard: available port
    end
    
    Dashboard->>Server: startServer({port, openspecDir})
    Server->>Server: create HTTP server with routes
    Server-->>Dashboard: server instance
    
    Dashboard->>Server: server.listen(port, '127.0.0.1')
    
    alt shouldOpen = true
        Dashboard->>Browser: openBrowser(url)
        Browser->>Server: GET /
        Server-->>Browser: dashboard HTML (SPA)
    end
    
    Browser->>Client: load SPA
    Client->>Server: GET /api/summary
    Server->>Data: getSummary(openspecDir)
    Data->>Data: gather changes, specs, archive counts
    Data-->>Server: summary data
    Server-->>Client: JSON response
    
    Client->>Server: GET /api/changes
    Server->>Data: getChangesData(openspecDir)
    Data->>Data: scan changes dir, detect artifacts, task progress
    Data-->>Server: changes array
    Server-->>Client: JSON response
    
    Client->>Server: GET /api/specs
    Server->>Data: getSpecsData(openspecDir)
    Data->>Data: parse specs, group by domain
    Data-->>Server: spec groups
    Server-->>Client: JSON response
    
    alt user clicks artifact
        Client->>Server: GET /api/artifact?path=changes/X/proposal.md
        Server->>Data: getArtifactContent(openspecDir, path)
        Data->>Data: validate path (traversal check)
        Data->>Data: read markdown file
        Data->>Data: renderMarkdown(content)
        Data-->>Server: rendered HTML
        Server-->>Client: JSON with html field
        Client->>Client: display in detail panel
    end
    
    User->>User: Ctrl+C
    Dashboard->>Server: shutdown signal (SIGINT/SIGTERM)
    Server->>Server: server.close()
    Dashboard->>User: exit process
Loading

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +34 to +41
server.listen(port, '127.0.0.1', () => {
console.log(`\nOpenSpec Dashboard running at ${url}`);
console.log('Press Ctrl+C to stop.\n');

if (shouldOpen) {
openBrowser(url);
}
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

race condition: server.listen() called before error handler attached

The error handler on line 43 is attached after server.listen() starts on line 34. If port binding fails immediately (e.g., EADDRINUSE), the error event fires before the handler is attached, causing an unhandled error.

Suggested change
server.listen(port, '127.0.0.1', () => {
console.log(`\nOpenSpec Dashboard running at ${url}`);
console.log('Press Ctrl+C to stop.\n');
if (shouldOpen) {
openBrowser(url);
}
});
const server = await startServer({ port, openspecDir, noOpen: !shouldOpen });
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
throw new Error(
`Port ${port} is already in use. Use --port to specify a different port.`
);
}
throw err;
});
server.listen(port, '127.0.0.1', () => {
console.log(`\nOpenSpec Dashboard running at ${url}`);
console.log('Press Ctrl+C to stop.\n');
if (shouldOpen) {
openBrowser(url);
}
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/dashboard/index.ts
Line: 34:41

Comment:
race condition: `server.listen()` called before error handler attached

The error handler on line 43 is attached after `server.listen()` starts on line 34. If port binding fails immediately (e.g., `EADDRINUSE`), the error event fires before the handler is attached, causing an unhandled error.

```suggestion
    const server = await startServer({ port, openspecDir, noOpen: !shouldOpen });

    server.on('error', (err: NodeJS.ErrnoException) => {
      if (err.code === 'EADDRINUSE') {
        throw new Error(
          `Port ${port} is already in use. Use --port to specify a different port.`
        );
      }
      throw err;
    });

    server.listen(port, '127.0.0.1', () => {
      console.log(`\nOpenSpec Dashboard running at ${url}`);
      console.log('Press Ctrl+C to stop.\n');

      if (shouldOpen) {
        openBrowser(url);
      }
    });
```

How can I resolve this? If you propose a fix, please make it concise.

@vibe-kanban-cloud
Copy link

Review Complete

Your review story is ready!

View Story

Comment !reviewfast on this PR to re-generate the story.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@src/core/dashboard/index.ts`:
- Around line 18-50: Validate the parsed port (requestedPort) immediately: if
parseInt yields NaN or the value is outside 1–65535, throw a user-friendly error
message telling the CLI to provide a valid port (e.g., via --port). When
starting the server (startServer / server), do not call server.listen() with a
callback that throws asynchronously; instead await an explicit Promise that
wraps server.listen and listens for the 'listening' and 'error' events (or use
once('listening')/once('error')) so any listen failure (including EADDRINUSE)
rejects the Promise and is catchable by the CLI's try/catch; keep the existing
logic that opens the browser (openBrowser) only after the await resolves.

In `@src/core/dashboard/markdown.ts`:
- Around line 15-34: In renderInline, protect inline code spans from subsequent
emphasis regexes and block unsafe link schemes: first find/back up all
`\`code\`` spans (e.g., using a placeholder array) right after escapeHtml so
later replaces for bold/italic/links don't touch `<code>` content, run
emphasis/link replacements, then restore the saved code span HTML; and when
building link hrefs (the /\[([^\]]+)\]\(([^)]+)\)/ handler) validate/sanitize
schemes by allowing only safe schemes (e.g., http, https, mailto, relative/path,
hash) and neutralize or reject unsafe ones like javascript: or data: before
inserting into the href. Ensure you update the code in function renderInline and
reference the existing code span replacement and link regex logic when making
these changes.

In `@src/core/dashboard/server.ts`:
- Around line 163-177: The specs badge currently generates a clickable link to
"changes/<name>/specs" which is a directory and the artifact endpoint only
serves .md/.yaml, causing 403; update the badge rendering code (e.g., the
function that builds badges/getArtifactUrl or renderArtifactBadge) to detect
when the artifact path has no concrete file extension (no .md/.yaml) or when the
backend listing indicates a directory and then render it as non-clickable (apply
the .artifact-badge.missing styling or remove the anchor/wrap) instead of
linking to the directory; ensure the same logic is applied where badges are
created (also update the other occurrence referenced around the 377-401 region).
🧹 Nitpick comments (1)
test/core/dashboard/markdown.test.ts (1)

31-34: Add coverage for emphasis markers inside inline code.

Line 31-34: add a regression test to ensure inline code stays literal when it contains * or **.

🧪 Suggested test addition
   it('renders inline code', () => {
     const html = renderMarkdown('Use `npm install` to install');
     expect(html).toContain('<code>npm install</code>');
   });
+
+  it('does not parse emphasis inside inline code', () => {
+    const html = renderMarkdown('Use `**bold**` literally');
+    expect(html).toContain('<code>**bold**</code>');
+  });

Comment on lines +18 to +50
const requestedPort = parseInt(options?.port || '3000', 10);
const shouldOpen = options?.open !== false;

let port: number;
if (options?.port) {
// Explicit port: use it directly, fail if unavailable
port = requestedPort;
} else {
// Default: auto-increment from 3000 to 3010
port = await findAvailablePort(3000, 3010);
}

const server = await startServer({ port, openspecDir, noOpen: !shouldOpen });

const url = `http://127.0.0.1:${port}`;

server.listen(port, '127.0.0.1', () => {
console.log(`\nOpenSpec Dashboard running at ${url}`);
console.log('Press Ctrl+C to stop.\n');

if (shouldOpen) {
openBrowser(url);
}
});

server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
throw new Error(
`Port ${port} is already in use. Use --port to specify a different port.`
);
}
throw err;
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "index.ts" | grep -E "dashboard|core" | head -20

Repository: Fission-AI/OpenSpec

Length of output: 319


🏁 Script executed:

cat -n src/core/dashboard/index.ts | head -100

Repository: Fission-AI/OpenSpec

Length of output: 2246


🏁 Script executed:

# Find the CLI entry point and understand error handling
git ls-files | grep -E "(cli|index|main|bin)" | head -20

Repository: Fission-AI/OpenSpec

Length of output: 1548


🏁 Script executed:

cat -n bin/openspec.js

Repository: Fission-AI/OpenSpec

Length of output: 135


🏁 Script executed:

# Check for package.json to understand the project structure
cat package.json | head -50

Repository: Fission-AI/OpenSpec

Length of output: 1201


🏁 Script executed:

# Search for how dashboard command is invoked
rg "DashboardCommand|dashboard" --type ts --type js -A 3 | head -100

Repository: Fission-AI/OpenSpec

Length of output: 6178


🏁 Script executed:

cat -n src/cli/index.ts | head -150

Repository: Fission-AI/OpenSpec

Length of output: 6513


🏁 Script executed:

# Test what parseInt does with invalid inputs
node -e "console.log('parseInt(\"foo\", 10):', parseInt('foo', 10)); console.log('parseInt(\"70000\", 10):', parseInt('70000', 10)); console.log('isNaN(NaN):', isNaN(NaN));"

Repository: Fission-AI/OpenSpec

Length of output: 133


🏁 Script executed:

# Check if there are any tests for the dashboard command
find . -type f -name "*.test.ts" -o -name "*.test.js" | xargs grep -l "DashboardCommand\|dashboard" 2>/dev/null

Repository: Fission-AI/OpenSpec

Length of output: 485


🏁 Script executed:

cat -n src/cli/index.ts | sed -n '150,250p'

Repository: Fission-AI/OpenSpec

Length of output: 4494


🏁 Script executed:

# Look for the dashboard command handler
rg "\.command\('dashboard'" -A 10 src/cli/index.ts

Repository: Fission-AI/OpenSpec

Length of output: 579


🏁 Script executed:

# Check if there are tests for the DashboardCommand
cat test/core/dashboard/data.test.ts | head -50

Repository: Fission-AI/OpenSpec

Length of output: 1666


🏁 Script executed:

# Check if there are tests for DashboardCommand.execute
rg "DashboardCommand|execute" test/core/dashboard/ -A 5

Repository: Fission-AI/OpenSpec

Length of output: 45


🏁 Script executed:

# Let's verify the exact behavior by looking at the server module
cat -n src/core/dashboard/server.ts | head -100

Repository: Fission-AI/OpenSpec

Length of output: 3140


🏁 Script executed:

# Test what happens with NaN as port
node -e "const port = NaN; const url = \`http://127.0.0.1:\${port}\`; console.log('URL with NaN port:', url);"

Repository: Fission-AI/OpenSpec

Length of output: 102


🏁 Script executed:

# Verify that errors thrown in event handlers don't propagate
node -e "
const http = require('http');
const server = http.createServer();

server.on('error', (err) => {
  throw new Error('Error from event handler');
});

try {
  server.listen(99999, '127.0.0.1', () => {
    console.log('Listening...');
  });
  setTimeout(() => {
    console.log('After listen callback, no error caught');
  }, 100);
} catch (e) {
  console.log('Caught in try/catch:', e.message);
}
"

Repository: Fission-AI/OpenSpec

Length of output: 154


🏁 Script executed:

# Check what happens when you use an explicit out-of-range port like 70000
node -e "
const port = 70000;
console.log('Port:', port);
console.log('isNaN(port):', isNaN(port));
const url = \`http://127.0.0.1:\${port}\`;
console.log('URL:', url);
"

Repository: Fission-AI/OpenSpec

Length of output: 121


🏁 Script executed:

# Verify behavior when listen() is called with invalid port (without await)
node -e "
const http = require('http');
const server = http.createServer((req, res) => res.end('ok'));

let errorCaught = false;

server.on('error', (err) => {
  errorCaught = true;
  console.log('Error caught in event handler:', err.code, err.message);
  throw new Error('Re-thrown from error handler');
});

try {
  server.listen(70000, '127.0.0.1', () => {
    console.log('Listen succeeded');
  });
  console.log('After listen() call (not awaited)');
  
  // Give time for async operations
  setTimeout(() => {
    console.log('After timeout - errorCaught:', errorCaught);
  }, 500);
} catch (e) {
  console.log('Caught in try/catch:', e.message);
}
" 2>&1 | head -20

Repository: Fission-AI/OpenSpec

Length of output: 154


🏁 Script executed:

# Test what happens with NaN port
node -e "
const http = require('http');
const server = http.createServer((req, res) => res.end('ok'));

const port = NaN; // Simulating parseInt('foo', 10)

server.on('error', (err) => {
  console.log('Error in handler:', err.code, err.message);
});

try {
  server.listen(port, '127.0.0.1', () => {
    console.log('Listen succeeded');
  });
  console.log('After listen() call');
  
  setTimeout(() => {
    console.log('Timeout reached');
  }, 500);
} catch (e) {
  console.log('Caught in try/catch:', e.message);
}
" 2>&1 | head -20

Repository: Fission-AI/OpenSpec

Length of output: 152


🏁 Script executed:

# Look at the full dashboard/index.ts to see if there's any other error handling
wc -l src/core/dashboard/index.ts
cat -n src/core/dashboard/index.ts

Repository: Fission-AI/OpenSpec

Length of output: 2277


🏁 Script executed:

# Look at the startServer function to understand what it returns
cat -n src/core/dashboard/server.ts | sed -n '200,300p'

Repository: Fission-AI/OpenSpec

Length of output: 3674


🏁 Script executed:

# Find the startServer export
rg "export.*startServer\|function startServer" src/core/dashboard/server.ts -A 20

Repository: Fission-AI/OpenSpec

Length of output: 45


🏁 Script executed:

# Check if startServer wraps the listen call
rg "listen|server\.listen" src/core/dashboard/server.ts -B 2 -A 2

Repository: Fission-AI/OpenSpec

Length of output: 282


🏁 Script executed:

# Find the startServer function definition
rg "export.*function startServer|export const startServer" src/core/dashboard/server.ts -A 30

Repository: Fission-AI/OpenSpec

Length of output: 901


🏁 Script executed:

# Search for startServer in the file
grep -n "startServer" src/core/dashboard/server.ts

Repository: Fission-AI/OpenSpec

Length of output: 148


🏁 Script executed:

# Get the file size and read the relevant sections
wc -l src/core/dashboard/server.ts

Repository: Fission-AI/OpenSpec

Length of output: 95


🏁 Script executed:

cat -n src/core/dashboard/server.ts | sed -n '489,550p'

Repository: Fission-AI/OpenSpec

Length of output: 2333


🏁 Script executed:

# Test what happens with EADDRINUSE - this is the real issue
node -e "
const http = require('http');

// Start a server on port 3000
const server1 = http.createServer();
server1.listen(3000, '127.0.0.1', () => {
  console.log('First server listening on 3000');
  
  // Now try to start another on the same port without awaiting
  const server2 = http.createServer();
  
  let errorCaught = false;
  server2.on('error', (err) => {
    errorCaught = true;
    console.log('Error caught in event handler:', err.code);
    throw new Error('Throwing from event handler');
  });
  
  try {
    server2.listen(3000, '127.0.0.1', () => {
      console.log('Second server listening (should not reach here)');
    });
    console.log('After listen() call - no error thrown yet');
    
    setTimeout(() => {
      console.log('After timeout - errorCaught:', errorCaught);
    }, 500);
  } catch (e) {
    console.log('Caught in try/catch:', e.message);
  }
});
" 2>&1 | head -20

Repository: Fission-AI/OpenSpec

Length of output: 499


Validate port input and surface listen errors to the CLI.

Lines 18-50: parseInt can yield NaN (e.g., --port foo) or accept out-of-range values without upfront validation. Additionally, errors thrown in the server.on('error') handler are asynchronous and won't be caught by the CLI's try/catch, causing the process to crash with an unhandled exception instead of reporting cleanly. Validate port values upfront and await listen() with a rejecting error handler so the CLI can catch and report all errors properly.

🛠️ Proposed fix (validation + awaitable listen)
-    const requestedPort = parseInt(options?.port || '3000', 10);
+    const requestedPort = options?.port ? Number(options.port) : 3000;
+    if (
+      options?.port &&
+      (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535)
+    ) {
+      throw new Error(`Invalid port "${options.port}". Use a number between 1 and 65535.`);
+    }
     const shouldOpen = options?.open !== false;
@@
-    server.listen(port, '127.0.0.1', () => {
-      console.log(`\nOpenSpec Dashboard running at ${url}`);
-      console.log('Press Ctrl+C to stop.\n');
-
-      if (shouldOpen) {
-        openBrowser(url);
-      }
-    });
-
-    server.on('error', (err: NodeJS.ErrnoException) => {
-      if (err.code === 'EADDRINUSE') {
-        throw new Error(
-          `Port ${port} is already in use. Use --port to specify a different port.`
-        );
-      }
-      throw err;
-    });
+    try {
+      await new Promise<void>((resolve, reject) => {
+        server.once('error', reject);
+        server.listen(port, '127.0.0.1', () => resolve());
+      });
+    } catch (err) {
+      const error = err as NodeJS.ErrnoException;
+      if (error.code === 'EADDRINUSE') {
+        throw new Error(
+          `Port ${port} is already in use. Use --port to specify a different port.`
+        );
+      }
+      throw error;
+    }
+
+    console.log(`\nOpenSpec Dashboard running at ${url}`);
+    console.log('Press Ctrl+C to stop.\n');
+
+    if (shouldOpen) {
+      openBrowser(url);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const requestedPort = parseInt(options?.port || '3000', 10);
const shouldOpen = options?.open !== false;
let port: number;
if (options?.port) {
// Explicit port: use it directly, fail if unavailable
port = requestedPort;
} else {
// Default: auto-increment from 3000 to 3010
port = await findAvailablePort(3000, 3010);
}
const server = await startServer({ port, openspecDir, noOpen: !shouldOpen });
const url = `http://127.0.0.1:${port}`;
server.listen(port, '127.0.0.1', () => {
console.log(`\nOpenSpec Dashboard running at ${url}`);
console.log('Press Ctrl+C to stop.\n');
if (shouldOpen) {
openBrowser(url);
}
});
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
throw new Error(
`Port ${port} is already in use. Use --port to specify a different port.`
);
}
throw err;
});
const requestedPort = options?.port ? Number(options.port) : 3000;
if (
options?.port &&
(!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535)
) {
throw new Error(`Invalid port "${options.port}". Use a number between 1 and 65535.`);
}
const shouldOpen = options?.open !== false;
let port: number;
if (options?.port) {
// Explicit port: use it directly, fail if unavailable
port = requestedPort;
} else {
// Default: auto-increment from 3000 to 3010
port = await findAvailablePort(3000, 3010);
}
const server = await startServer({ port, openspecDir, noOpen: !shouldOpen });
const url = `http://127.0.0.1:${port}`;
try {
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', () => resolve());
});
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'EADDRINUSE') {
throw new Error(
`Port ${port} is already in use. Use --port to specify a different port.`
);
}
throw error;
}
console.log(`\nOpenSpec Dashboard running at ${url}`);
console.log('Press Ctrl+C to stop.\n');
if (shouldOpen) {
openBrowser(url);
}
🤖 Prompt for AI Agents
In `@src/core/dashboard/index.ts` around lines 18 - 50, Validate the parsed port
(requestedPort) immediately: if parseInt yields NaN or the value is outside
1–65535, throw a user-friendly error message telling the CLI to provide a valid
port (e.g., via --port). When starting the server (startServer / server), do not
call server.listen() with a callback that throws asynchronously; instead await
an explicit Promise that wraps server.listen and listens for the 'listening' and
'error' events (or use once('listening')/once('error')) so any listen failure
(including EADDRINUSE) rejects the Promise and is catchable by the CLI's
try/catch; keep the existing logic that opens the browser (openBrowser) only
after the await resolves.

Comment on lines +15 to +34
function renderInline(text: string): string {
let result = escapeHtml(text);

// Inline code (must come before bold/italic to avoid conflicts)
result = result.replace(/`([^`]+)`/g, '<code>$1</code>');

// Bold + italic
result = result.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');

// Bold
result = result.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');

// Italic
result = result.replace(/\*(.+?)\*/g, '<em>$1</em>');

// Links
result = result.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener">$1</a>'
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Block unsafe link schemes and keep emphasis out of inline code.

Line 18-34: inline code is inserted before bold/italic, but the later emphasis regex still runs on the <code> content (e.g., `**x**` renders bold inside code). Also, links accept any scheme; javascript:/data: should be neutralized to avoid XSS in the dashboard UI.

🛡️ Proposed fix (sanitize schemes + protect code spans)
 function escapeHtml(text: string): string {
   return text
     .replace(/&/g, '&amp;')
     .replace(/</g, '&lt;')
     .replace(/>/g, '&gt;')
     .replace(/"/g, '&quot;');
 }
 
+function sanitizeHref(href: string): string {
+  const normalized = href.trim().toLowerCase();
+  if (normalized.startsWith('javascript:') || normalized.startsWith('data:')) {
+    return '#';
+  }
+  return href;
+}
+
 function renderInline(text: string): string {
   let result = escapeHtml(text);
 
   // Inline code (must come before bold/italic to avoid conflicts)
-  result = result.replace(/`([^`]+)`/g, '<code>$1</code>');
+  const codeSpans: string[] = [];
+  result = result.replace(/`([^`]+)`/g, (_match, code) => {
+    const token = `@@CODE_${codeSpans.length}@@`;
+    codeSpans.push(`<code>${code}</code>`);
+    return token;
+  });
 
   // Bold + italic
   result = result.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
@@
   // Links
   result = result.replace(
     /\[([^\]]+)\]\(([^)]+)\)/g,
-    '<a href="$2" target="_blank" rel="noopener">$1</a>'
+    (_match, label, href) =>
+      `<a href="${sanitizeHref(href)}" target="_blank" rel="noopener">${label}</a>`
   );
 
+  codeSpans.forEach((html, index) => {
+    result = result.replace(`@@CODE_${index}@@`, html);
+  });
+
   return result;
 }
🤖 Prompt for AI Agents
In `@src/core/dashboard/markdown.ts` around lines 15 - 34, In renderInline,
protect inline code spans from subsequent emphasis regexes and block unsafe link
schemes: first find/back up all `\`code\`` spans (e.g., using a placeholder
array) right after escapeHtml so later replaces for bold/italic/links don't
touch `<code>` content, run emphasis/link replacements, then restore the saved
code span HTML; and when building link hrefs (the /\[([^\]]+)\]\(([^)]+)\)/
handler) validate/sanitize schemes by allowing only safe schemes (e.g., http,
https, mailto, relative/path, hash) and neutralize or reject unsafe ones like
javascript: or data: before inserting into the href. Ensure you update the code
in function renderInline and reference the existing code span replacement and
link regex logic when making these changes.

Comment on lines +163 to +177
.artifact-badge.present {
background: var(--surface2);
color: var(--accent);
border: 1px solid var(--accent);
}
.artifact-badge.missing {
background: transparent;
color: var(--text-dim);
border: 1px solid var(--border);
cursor: default;
}
.artifact-badge.present:hover {
background: var(--accent);
color: var(--bg);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Specs badge always fails to load (directory path).

The specs badge points to changes/<name>/specs, but the artifact endpoint only allows .md/.yaml. Clicking “specs” will always return 403. Consider making it non-clickable until a concrete file or listing exists.

🛠️ Proposed fix (disable click when no file path)
@@
   .artifact-badge.present {
     background: var(--surface2);
     color: var(--accent);
     border: 1px solid var(--accent);
   }
+  .artifact-badge.present.no-click { cursor: default; }
+  .artifact-badge.present.no-click:hover {
+    background: var(--surface2);
+    color: var(--accent);
+  }
@@
   const artifacts = [
     badge('proposal', c.artifacts.proposal, 'changes/' + c.name + '/proposal.md'),
-    badge('specs', c.artifacts.specs, 'changes/' + c.name + '/specs'),
+    badge('specs', c.artifacts.specs),
     badge('design', c.artifacts.design, 'changes/' + c.name + '/design.md'),
     badge('tasks', c.artifacts.tasks, 'changes/' + c.name + '/tasks.md'),
   ].join('');
@@
 function badge(label, present, path) {
-  if (present) {
+  if (present && path) {
     return '<span class="artifact-badge present" onclick="viewArtifact(\\'' + path + '\\',\\'' + label + '\\')">' + label + '</span>';
   }
+  if (present) {
+    return '<span class="artifact-badge present no-click">' + label + '</span>';
+  }
   return '<span class="artifact-badge missing">' + label + '</span>';
 }

Also applies to: 377-401

🤖 Prompt for AI Agents
In `@src/core/dashboard/server.ts` around lines 163 - 177, The specs badge
currently generates a clickable link to "changes/<name>/specs" which is a
directory and the artifact endpoint only serves .md/.yaml, causing 403; update
the badge rendering code (e.g., the function that builds badges/getArtifactUrl
or renderArtifactBadge) to detect when the artifact path has no concrete file
extension (no .md/.yaml) or when the backend listing indicates a directory and
then render it as non-clickable (apply the .artifact-badge.missing styling or
remove the anchor/wrap) instead of linking to the directory; ensure the same
logic is applied where badges are created (also update the other occurrence
referenced around the 377-401 region).

@TabishB TabishB changed the title feat: add openspec dashboard command for web-based browsing [With Old OpenSpec New Version] feat: add openspec dashboard command for web-based browsing Jan 28, 2026
@TabishB TabishB changed the title [With Old OpenSpec New Version] feat: add openspec dashboard command for web-based browsing [With New OpenSpec Version] feat: add openspec dashboard command for web-based browsing Jan 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants