-
Notifications
You must be signed in to change notification settings - Fork 1.4k
[With New OpenSpec Version] feat: add openspec dashboard command for web-based browsing #615
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
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).
📝 WalkthroughWalkthroughThis 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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
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. Comment |
Greptile OverviewGreptile SummaryImplements Key implementation highlights:
Minor issue found:
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
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
|
There was a problem hiding this 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
| 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); | ||
| } | ||
| }); |
There was a problem hiding this comment.
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.
| 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.
Review CompleteYour review story is ready! Comment !reviewfast on this PR to re-generate the story. |
There was a problem hiding this 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>'); + });
| 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; | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "index.ts" | grep -E "dashboard|core" | head -20Repository: Fission-AI/OpenSpec
Length of output: 319
🏁 Script executed:
cat -n src/core/dashboard/index.ts | head -100Repository: 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 -20Repository: Fission-AI/OpenSpec
Length of output: 1548
🏁 Script executed:
cat -n bin/openspec.jsRepository: Fission-AI/OpenSpec
Length of output: 135
🏁 Script executed:
# Check for package.json to understand the project structure
cat package.json | head -50Repository: 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 -100Repository: Fission-AI/OpenSpec
Length of output: 6178
🏁 Script executed:
cat -n src/cli/index.ts | head -150Repository: 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/nullRepository: 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.tsRepository: 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 -50Repository: Fission-AI/OpenSpec
Length of output: 1666
🏁 Script executed:
# Check if there are tests for DashboardCommand.execute
rg "DashboardCommand|execute" test/core/dashboard/ -A 5Repository: 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 -100Repository: 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 -20Repository: 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 -20Repository: 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.tsRepository: 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 20Repository: 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 2Repository: 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 30Repository: Fission-AI/OpenSpec
Length of output: 901
🏁 Script executed:
# Search for startServer in the file
grep -n "startServer" src/core/dashboard/server.tsRepository: Fission-AI/OpenSpec
Length of output: 148
🏁 Script executed:
# Get the file size and read the relevant sections
wc -l src/core/dashboard/server.tsRepository: 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 -20Repository: 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.
| 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.
| 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>' | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
+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.
| .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); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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).
Summary
Implements a new
openspec dashboardcommand 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-basedviewcommand 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
httpmodule (no Express/Fastify dependency)--portoverride and--no-openflag🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
dashboardcommand to launch a local web-based dashboard for viewing project changes, specifications, and archive entries--portoption for custom port selection and--no-openflag to disable automatic browser launchChores
✏️ Tip: You can customize this high-level summary in your review settings.