-
Notifications
You must be signed in to change notification settings - Fork 1.4k
[Without OpenSpec] Add openspec dashboard web command #612
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
Introduces a new `openspec dashboard` command that spins up a local HTTP server and opens an interactive web dashboard. The dashboard displays active changes with their artifact status, main specs organized by domain, and archive history. Users can view any artifact's rendered markdown content in a modal viewer. Features: - Summary stats showing specs, requirements, and change counts - Changes tab with expandable cards showing artifact status and progress - Specs tab organized by domain with requirement counts - Archive tab with collapsible history of completed changes - Markdown viewer overlay for viewing artifact content - Automatic browser launch and graceful shutdown
📝 WalkthroughWalkthroughThe changes introduce a new dashboard CLI command that launches a local HTTP server to display OpenSpec data interactively. The implementation includes a self-contained DashboardCommand that scans change logs, specifications, and archives, then serves a single-page dashboard with Markdown viewing capabilities and real-time progress tracking. Changes
Sequence DiagramssequenceDiagram
actor User
participant CLI
participant DashboardCommand
participant FileSystem
participant HTTPServer
participant Browser
User->>CLI: dashboard --port 3000
CLI->>DashboardCommand: execute('.', { port: 3000 })
DashboardCommand->>FileSystem: validate openspec directory
DashboardCommand->>FileSystem: scan changes, specs, archive
DashboardCommand->>HTTPServer: start server on port 3000
DashboardCommand->>Browser: open browser
Browser->>HTTPServer: GET /
HTTPServer->>Browser: return HTML dashboard UI
Browser->>HTTPServer: GET /api/data
HTTPServer->>FileSystem: read changes & specs metadata
HTTPServer->>Browser: return JSON data
Browser->>Browser: render dashboard with stats
sequenceDiagram
participant Browser
participant HTTPServer
participant FileSystem
participant MarkdownParser
Browser->>Browser: user clicks on change/artifact
Browser->>HTTPServer: GET /api/artifact?path=...
HTTPServer->>FileSystem: read artifact markdown file
HTTPServer->>HTTPServer: validate path (prevent traversal)
HTTPServer->>Browser: return file content
Browser->>Browser: render markdown in viewer overlay
alt archive file request
Browser->>HTTPServer: GET /api/archive-files?name=...
HTTPServer->>FileSystem: list markdown files in archive path
HTTPServer->>Browser: return file list
Browser->>Browser: lazy-load and display files
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 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 |
Review CompleteYour review story is ready! Comment !reviewfast on this PR to re-generate the story. |
Greptile OverviewGreptile SummaryAdds a new Major changes:
Critical issues found:
The concept is solid and follows patterns from the existing Confidence Score: 1/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant CLI as CLI (index.ts)
participant Dashboard as DashboardCommand
participant Server as HTTP Server
participant Browser
participant FS as File System
User->>CLI: openspec dashboard --port 3000
CLI->>Dashboard: new DashboardCommand()
CLI->>Dashboard: execute('.', {port: 3000})
Dashboard->>FS: Check openspecDir exists
FS-->>Dashboard: Directory found
Dashboard->>Server: createServer()
Server->>Server: Listen on port 3000
Dashboard->>Browser: exec(open/start/xdg-open)
Browser->>Server: GET /
Server->>Browser: Return HTML dashboard
Browser->>Server: GET /api/data
Server->>FS: getChanges(openspecDir)
Server->>FS: getSpecs(openspecDir)
Server->>FS: getArchive(openspecDir)
FS-->>Server: Changes, Specs, Archive data
Server->>Browser: JSON response
Browser->>Browser: Render tabs and stats
User->>Browser: Click on artifact
Browser->>Server: GET /api/artifact?path=...
Server->>FS: readArtifact(openspecDir, path)
FS-->>Server: Markdown content
Server->>Browser: JSON with content
Browser->>Browser: renderMarkdown() & display
User->>Browser: Click archive
Browser->>Server: GET /api/archive-files?name=...
Server->>FS: listMarkdownFiles(archiveDir)
FS-->>Server: File list
Server->>Browser: JSON with files
User->>CLI: Ctrl+C
Dashboard->>Server: server.close()
Server-->>Dashboard: Shutdown complete
|
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.
2 files reviewed, 7 comments
| const res = await fetch('/api/artifact?path=' + encodeURIComponent(path)); | ||
| const json = await res.json(); | ||
| document.getElementById('viewer-title').textContent = title || path; | ||
| document.getElementById('viewer-content').innerHTML = renderMarkdown(json.content || 'File not found.'); |
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.
XSS vulnerability - renderMarkdown returns HTML that's inserted via innerHTML without sanitization. Malicious markdown in spec files could execute JavaScript in users' browsers.
| document.getElementById('viewer-content').innerHTML = renderMarkdown(json.content || 'File not found.'); | |
| document.getElementById('viewer-content').textContent = json.content || 'File not found.'; |
Or use a proper markdown library with XSS protection like marked with DOMPurify.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/dashboard.ts
Line: 441:441
Comment:
XSS vulnerability - `renderMarkdown` returns HTML that's inserted via `innerHTML` without sanitization. Malicious markdown in spec files could execute JavaScript in users' browsers.
```suggestion
document.getElementById('viewer-content').textContent = json.content || 'File not found.';
```
Or use a proper markdown library with XSS protection like `marked` with DOMPurify.
How can I resolve this? If you propose a fix, please make it concise.| : process.platform === 'win32' | ||
| ? 'start' | ||
| : 'xdg-open'; | ||
| exec(`${openCmd} ${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.
Command injection risk - exec is called with unsanitized url variable. While url is constructed from localhost and a validated port, this pattern is risky. Use spawn or execFile with array arguments instead.
| exec(`${openCmd} ${url}`); | |
| const { spawn } = require('node:child_process'); | |
| spawn(openCmd, [url], { detached: true, stdio: 'ignore' }).unref(); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/dashboard.ts
Line: 742:742
Comment:
Command injection risk - `exec` is called with unsanitized `url` variable. While `url` is constructed from localhost and a validated port, this pattern is risky. Use `spawn` or `execFile` with array arguments instead.
```suggestion
const { spawn } = require('node:child_process');
spawn(openCmd, [url], { detached: true, stdio: 'ignore' }).unref();
```
How can I resolve this? If you propose a fix, please make it concise.| \${change.artifacts.map(a => { | ||
| const isClickable = a.status === 'done'; | ||
| const clickAttr = isClickable | ||
| ? \`class="artifact-name clickable" onclick="openViewer('changes/\${change.name}/\${a.outputPath}', '\${change.name} / \${a.id}')"\` |
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.
Potential XSS - change.name and a.id are inserted into onclick attributes without escaping. If directory/artifact names contain quotes or special chars, this could break out of the attribute context.
Use encodeURIComponent for the path parameter and escape the title parameter, or better yet, use event listeners instead of inline onclick.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/dashboard.ts
Line: 542:542
Comment:
Potential XSS - `change.name` and `a.id` are inserted into `onclick` attributes without escaping. If directory/artifact names contain quotes or special chars, this could break out of the attribute context.
Use `encodeURIComponent` for the path parameter and escape the title parameter, or better yet, use event listeners instead of inline `onclick`.
How can I resolve this? If you propose a fix, please make it concise.| let html = escapeHtml(src); | ||
| // Code blocks | ||
| html = html.replace(/\`\`\`(\\w*?)\\n([\\s\\S]*?)\`\`\`/g, '<pre><code>$2</code></pre>'); |
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.
Regex will fail to match code blocks with language specifiers. The \w*? uses excessive escaping - should be (\w*) not (\\w*?).
| html = html.replace(/\`\`\`(\\w*?)\\n([\\s\\S]*?)\`\`\`/g, '<pre><code>$2</code></pre>'); | |
| html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>'); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/dashboard.ts
Line: 455:455
Comment:
Regex will fail to match code blocks with language specifiers. The `\w*?` uses excessive escaping - should be `(\w*)` not `(\\w*?)`.
```suggestion
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
```
How can I resolve this? If you propose a fix, please make it concise.| html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>'); | ||
| html = html.replace(/\\*(.+?)\\*/g, '<em>$1</em>'); |
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.
Same regex escaping issue - \\*\\* and \\* should be \*\* and \* (single backslash for JavaScript string, becomes one backslash in regex).
| html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>'); | |
| html = html.replace(/\\*(.+?)\\*/g, '<em>$1</em>'); | |
| html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); | |
| html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/dashboard.ts
Line: 467:468
Comment:
Same regex escaping issue - `\\*\\*` and `\\*` should be `\*\*` and `\*` (single backslash for JavaScript string, becomes one backslash in regex).
```suggestion
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
```
How can I resolve this? If you propose a fix, please make it concise.| html = html.replace(/^- \\[x\\] (.+)$/gm, '<li style="list-style:none">☑ $1</li>'); | ||
| html = html.replace(/^- \\[ \\] (.+)$/gm, '<li style="list-style:none">☐ $1</li>'); |
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.
Over-escaped regex patterns - \\[x\\] should be \[x\] and \\[ \\] should be \[ \].
| html = html.replace(/^- \\[x\\] (.+)$/gm, '<li style="list-style:none">☑ $1</li>'); | |
| html = html.replace(/^- \\[ \\] (.+)$/gm, '<li style="list-style:none">☐ $1</li>'); | |
| html = html.replace(/^- \[x\] (.+)$/gm, '<li style="list-style:none">☑ $1</li>'); | |
| html = html.replace(/^- \[ \] (.+)$/gm, '<li style="list-style:none">☐ $1</li>'); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/dashboard.ts
Line: 477:478
Comment:
Over-escaped regex patterns - `\\[x\\]` should be `\[x\]` and `\\[ \\]` should be `\[ \]`.
```suggestion
html = html.replace(/^- \[x\] (.+)$/gm, '<li style="list-style:none">☑ $1</li>');
html = html.replace(/^- \[ \] (.+)$/gm, '<li style="list-style:none">☐ $1</li>');
```
How can I resolve this? If you propose a fix, please make it concise.| html = html.replace(/^- (.+)$/gm, '<li>$1</li>'); | ||
| // Links | ||
| html = html.replace(/\\[(.+?)\\]\\((.+?)\\)/g, '<a href="$2" target="_blank">$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.
Over-escaped regex - \\[(.+?)\\]\\((.+?)\\) should be \[(.+?)\]\((.+?)\).
| html = html.replace(/\\[(.+?)\\]\\((.+?)\\)/g, '<a href="$2" target="_blank">$1</a>'); | |
| html = html.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" target="_blank">$1</a>'); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/dashboard.ts
Line: 484:484
Comment:
Over-escaped regex - `\\[(.+?)\\]\\((.+?)\\)` should be `\[(.+?)\]\((.+?)\)`.
```suggestion
html = html.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" target="_blank">$1</a>');
```
How can I resolve this? If you propose a fix, please make it concise.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/cli/index.ts`:
- Around line 205-213: Validate the --port value before calling
DashboardCommand.execute: parse options?.port to an integer, ensure it's a
positive integer within the valid TCP port range (1–65535), and if invalid throw
or print a clear error and exit early instead of calling new
DashboardCommand().execute; update the dashboard command action handler that
constructs DashboardCommand to perform this check and only pass a valid port (or
undefined) to DashboardCommand.execute.
In `@src/core/dashboard.ts`:
- Around line 158-167: The current containment check in readArtifact using
startsWith is insecure; replace it with a path.relative-based guard (e.g.,
extract a helper isSubpath(parent, child) that computes rel =
path.relative(parent, child) and returns rel !== '' && !rel.startsWith('..') &&
!path.isAbsolute(rel) or adjust for allowing same dir) and use that helper in
readArtifact and the archive listing logic (the code handling archive listings
elsewhere) to ensure resolved paths cannot escape the openspecDir; preserve the
existing fs.existsSync and readFileSync behavior and return null on failures as
before.
- Around line 669-671: Replace the wildcard CORS and open binding: remove or
stop setting res.setHeader('Access-Control-Allow-Origin', '*') in the dashboard
response handling and instead either drop the header entirely (preferred if the
UI is served by this same server) or set it to the server's same‑origin value;
also change the server bind from listening on all interfaces to bind to
127.0.0.1 (update the server.listen/createServer call that currently uses
0.0.0.0 or no host) so the dashboard is only accessible locally. Ensure you
update all occurrences (the res.setHeader(...) lines and the server.listen(...)
invocation(s) referenced in this module).
| program | ||
| .command('dashboard') | ||
| .description('Open an interactive web dashboard for specs and changes') | ||
| .option('--port <port>', 'Port to run the server on', '3000') | ||
| .action(async (options?: { port?: string }) => { | ||
| try { | ||
| const dashboardCommand = new DashboardCommand(); | ||
| await dashboardCommand.execute('.', { port: options?.port ? parseInt(options.port, 10) : undefined }); | ||
| } catch (error) { |
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.
Validate --port input before launching the server.
Negative or out‑of‑range values will cause server.listen to throw. Validate early and return a clear error.
✅ Proposed fix (range validation)
.action(async (options?: { port?: string }) => {
try {
- const dashboardCommand = new DashboardCommand();
- await dashboardCommand.execute('.', { port: options?.port ? parseInt(options.port, 10) : undefined });
+ const rawPort = options?.port ? Number(options.port) : undefined;
+ if (rawPort !== undefined && (!Number.isInteger(rawPort) || rawPort < 1 || rawPort > 65535)) {
+ throw new Error(`Invalid port "${options?.port}". Use an integer between 1 and 65535.`);
+ }
+ const dashboardCommand = new DashboardCommand();
+ await dashboardCommand.execute('.', { port: rawPort });
} catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});🤖 Prompt for AI Agents
In `@src/cli/index.ts` around lines 205 - 213, Validate the --port value before
calling DashboardCommand.execute: parse options?.port to an integer, ensure it's
a positive integer within the valid TCP port range (1–65535), and if invalid
throw or print a clear error and exit early instead of calling new
DashboardCommand().execute; update the dashboard command action handler that
constructs DashboardCommand to perform this check and only pass a valid port (or
undefined) to DashboardCommand.execute.
| function readArtifact(openspecDir: string, artifactPath: string): string | null { | ||
| // Resolve to prevent path traversal | ||
| const resolved = path.resolve(openspecDir, artifactPath); | ||
| if (!resolved.startsWith(path.resolve(openspecDir))) return null; | ||
| if (!fs.existsSync(resolved)) return null; | ||
| try { | ||
| return fs.readFileSync(resolved, 'utf-8'); | ||
| } catch { | ||
| return null; | ||
| } |
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.
Harden path containment checks to prevent traversal.
The current startsWith check is bypassable via .. segments (e.g., resolving to a sibling path that still shares the same prefix). That allows reading/listing files outside the intended directory. Use a path.relative-based guard and reuse it for both artifact reads and archive listings.
🔒 Proposed fix (shared subpath guard)
+function isSubpath(root: string, target: string): boolean {
+ const rel = path.relative(root, target);
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
+}
+
function readArtifact(openspecDir: string, artifactPath: string): string | null {
// Resolve to prevent path traversal
- const resolved = path.resolve(openspecDir, artifactPath);
- if (!resolved.startsWith(path.resolve(openspecDir))) return null;
+ const root = path.resolve(openspecDir);
+ const resolved = path.resolve(root, artifactPath);
+ if (!isSubpath(root, resolved)) return null;
if (!fs.existsSync(resolved)) return null;
try {
return fs.readFileSync(resolved, 'utf-8');
} catch {
return null;
}
}
...
if (url.pathname === '/api/archive-files') {
const name = url.searchParams.get('name') || '';
- const archiveItemDir = path.join(openspecDir, 'changes', 'archive', name);
+ const archiveRoot = path.join(openspecDir, 'changes', 'archive');
+ const archiveItemDir = path.resolve(archiveRoot, name);
// Prevent path traversal
- if (!path.resolve(archiveItemDir).startsWith(path.resolve(openspecDir))) {
+ if (!isSubpath(archiveRoot, archiveItemDir)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid path' }));
return;
}Also applies to: 702-711
🤖 Prompt for AI Agents
In `@src/core/dashboard.ts` around lines 158 - 167, The current containment check
in readArtifact using startsWith is insecure; replace it with a
path.relative-based guard (e.g., extract a helper isSubpath(parent, child) that
computes rel = path.relative(parent, child) and returns rel !== '' &&
!rel.startsWith('..') && !path.isAbsolute(rel) or adjust for allowing same dir)
and use that helper in readArtifact and the archive listing logic (the code
handling archive listings elsewhere) to ensure resolved paths cannot escape the
openspecDir; preserve the existing fs.existsSync and readFileSync behavior and
return null on failures as before.
| // CORS headers for local dev | ||
| res.setHeader('Access-Control-Allow-Origin', '*'); | ||
|
|
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.
Limit dashboard exposure to localhost and remove wildcard CORS.
Listening on all interfaces plus Access-Control-Allow-Origin: * allows any origin/network host to read dashboard data. Bind to 127.0.0.1 and only allow same‑origin (or drop CORS entirely since the UI is served by this server).
🔒 Proposed fix (localhost bind + same‑origin CORS)
- const server = http.createServer(async (req, res) => {
- const url = new URL(req.url || '/', `http://localhost:${port}`);
-
- // CORS headers for local dev
- res.setHeader('Access-Control-Allow-Origin', '*');
+ const host = '127.0.0.1';
+ const origin = `http://${host}:${port}`;
+ const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url || '/', origin);
+ // Same-origin only (UI served by this server)
+ if (req.headers.origin === origin) {
+ res.setHeader('Access-Control-Allow-Origin', origin);
+ }
...
- server.listen(port, () => {
- const url = `http://localhost:${port}`;
+ server.listen(port, host, () => {
+ const url = origin;
console.log(chalk.bold('\nOpenSpec Dashboard'));
console.log(`Server running at ${chalk.cyan(url)}`);
console.log(chalk.dim('Press Ctrl+C to stop.\n'));Also applies to: 730-742
🤖 Prompt for AI Agents
In `@src/core/dashboard.ts` around lines 669 - 671, Replace the wildcard CORS and
open binding: remove or stop setting
res.setHeader('Access-Control-Allow-Origin', '*') in the dashboard response
handling and instead either drop the header entirely (preferred if the UI is
served by this same server) or set it to the server's same‑origin value; also
change the server bind from listening on all interfaces to bind to 127.0.0.1
(update the server.listen/createServer call that currently uses 0.0.0.0 or no
host) so the dashboard is only accessible locally. Ensure you update all
occurrences (the res.setHeader(...) lines and the server.listen(...)
invocation(s) referenced in this module).
Summary
openspec dashboardcommand spins up a local web server with interactive UI🤖 Generated with Claude Code
Summary by CodeRabbit
dashboardCLI command that launches an interactive web-based interface for viewing OpenSpec data locally.--portoption (default 3000) for hosting the dashboard server.✏️ Tip: You can customize this high-level summary in your review settings.