Skip to content

Commit f45ba73

Browse files
authored
feat: add openspec dashboard command for web-based project browsing (Fission-AI#615)
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).
1 parent 4130575 commit f45ba73

9 files changed

Lines changed: 1713 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
## 1. Project Setup and Command Registration
2+
3+
- [x] 1.1 Create directory structure: `src/core/dashboard/` with `index.ts`, `server.ts`, `data.ts`, `markdown.ts`
4+
- [x] 1.2 Register `openspec dashboard` command in `src/cli/index.ts` with `--port` and `--no-open` options
5+
- [x] 1.3 Create `DashboardCommand` class in `src/core/dashboard/index.ts` that validates openspec directory exists
6+
7+
## 2. Data Gathering Module
8+
9+
- [x] 2.1 Implement `getChangesData()` in `data.ts` that returns draft/active/completed changes with artifact existence status (proposal.md, specs/, design.md, tasks.md)
10+
- [x] 2.2 Implement `getSpecsData()` in `data.ts` that returns specs grouped by domain prefix with requirement counts
11+
- [x] 2.3 Implement `getArchiveData()` in `data.ts` that returns archived changes with parsed dates, sorted reverse chronologically
12+
- [x] 2.4 Implement `getSummary()` in `data.ts` that aggregates counts for dashboard summary section
13+
- [x] 2.5 Implement `getArtifactContent()` in `data.ts` that reads a markdown file by relative path with path traversal protection
14+
15+
## 3. Markdown Renderer
16+
17+
- [x] 3.1 Implement markdown-to-HTML converter in `markdown.ts` handling headings, paragraphs, bold, italic, and line breaks
18+
- [x] 3.2 Add support for fenced code blocks and inline code
19+
- [x] 3.3 Add support for unordered lists, ordered lists, and checkboxes
20+
- [x] 3.4 Add support for blockquotes, horizontal rules, and links
21+
22+
## 4. HTTP Server and API
23+
24+
- [x] 4.1 Implement HTTP server in `server.ts` using Node.js built-in `http` module
25+
- [x] 4.2 Add route `GET /` that serves the embedded single-page HTML dashboard
26+
- [x] 4.3 Add route `GET /api/summary` returning aggregated project data
27+
- [x] 4.4 Add route `GET /api/changes` returning changes with artifact status
28+
- [x] 4.5 Add route `GET /api/specs` returning specs grouped by domain
29+
- [x] 4.6 Add route `GET /api/archive` returning archive entries with pagination (default 50)
30+
- [x] 4.7 Add route `GET /api/artifact?path=<relative>` returning rendered markdown HTML with path traversal guard
31+
- [x] 4.8 Implement port selection with auto-increment from default 3000 to 3010
32+
- [x] 4.9 Implement cross-platform browser opening (open/xdg-open/start)
33+
- [x] 4.10 Add graceful shutdown on SIGINT/SIGTERM
34+
35+
## 5. Dashboard HTML/CSS/JS
36+
37+
- [x] 5.1 Create embedded HTML template with navigation tabs for Changes, Specs, and Archive sections
38+
- [x] 5.2 Implement Changes view with status grouping (draft/active/completed), artifact indicators, and progress bars
39+
- [x] 5.3 Implement Specs view with domain-prefix grouping and requirement count display
40+
- [x] 5.4 Implement Archive view with date-based listing and "load more" pagination
41+
- [x] 5.5 Implement artifact detail panel that fetches and displays rendered markdown on click
42+
- [x] 5.6 Add basic responsive CSS styling with monospace fonts matching terminal aesthetic
43+
44+
## 6. Testing
45+
46+
- [x] 6.1 Add unit tests for markdown renderer in `test/core/dashboard/markdown.test.ts`
47+
- [x] 6.2 Add unit tests for data gathering functions in `test/core/dashboard/data.test.ts`
48+
- [x] 6.3 Add unit tests for path traversal prevention in artifact endpoint
49+
- [x] 6.4 Add unit tests for port selection logic
50+
- [x] 6.5 Add unit tests for domain grouping logic
51+
- [x] 6.6 Add unit tests for archive date parsing
52+
53+
## 7. Polish and Integration
54+
55+
- [x] 7.1 Add CLI help text for the dashboard command
56+
- [x] 7.2 Add shell completion entries for dashboard command and its options
57+
- [x] 7.3 Handle edge cases: empty project, missing directories, unparseable specs
58+
- [x] 7.4 Ensure cross-platform path handling with `path.join()` throughout
59+
- [x] 7.5 Run linting and fix any issues (`pnpm run lint`)
60+
- [x] 7.6 Run full test suite (`pnpm test`)

src/cli/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,23 @@ program
201201
}
202202
});
203203

204+
program
205+
.command('dashboard')
206+
.description('Start a local web-based dashboard for browsing changes, specs, and archive')
207+
.option('--port <number>', 'Server port (default: 3000)')
208+
.option('--no-open', 'Do not open browser automatically')
209+
.action(async (options?: { port?: string; open?: boolean }) => {
210+
try {
211+
const { DashboardCommand } = await import('../core/dashboard/index.js');
212+
const cmd = new DashboardCommand();
213+
await cmd.execute('.', options);
214+
} catch (error) {
215+
console.log();
216+
ora().fail(`Error: ${(error as Error).message}`);
217+
process.exit(1);
218+
}
219+
});
220+
204221
// Change command with subcommands
205222
const changeCmd = program
206223
.command('change')

src/core/dashboard/data.ts

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import { promises as fs } from 'fs';
2+
import path from 'path';
3+
import { getTaskProgressForChange, type TaskProgress } from '../../utils/task-progress.js';
4+
import { MarkdownParser } from '../parsers/markdown-parser.js';
5+
import { renderMarkdown } from './markdown.js';
6+
7+
export interface ChangeArtifacts {
8+
proposal: boolean;
9+
specs: boolean;
10+
design: boolean;
11+
tasks: boolean;
12+
}
13+
14+
export interface ChangeEntry {
15+
name: string;
16+
status: 'draft' | 'active' | 'completed';
17+
artifacts: ChangeArtifacts;
18+
progress: TaskProgress;
19+
}
20+
21+
export interface SpecEntry {
22+
name: string;
23+
requirementCount: number;
24+
}
25+
26+
export interface SpecGroup {
27+
domain: string;
28+
specs: SpecEntry[];
29+
}
30+
31+
export interface ArchiveEntry {
32+
name: string;
33+
date: string;
34+
changeName: string;
35+
}
36+
37+
export interface DashboardSummary {
38+
changes: { draft: number; active: number; completed: number; total: number };
39+
specs: { total: number; totalRequirements: number };
40+
archive: { total: number };
41+
}
42+
43+
async function dirExists(dirPath: string): Promise<boolean> {
44+
try {
45+
const stat = await fs.stat(dirPath);
46+
return stat.isDirectory();
47+
} catch {
48+
return false;
49+
}
50+
}
51+
52+
async function fileExists(filePath: string): Promise<boolean> {
53+
try {
54+
await fs.access(filePath);
55+
return true;
56+
} catch {
57+
return false;
58+
}
59+
}
60+
61+
async function getArtifacts(changeDir: string): Promise<ChangeArtifacts> {
62+
const [proposal, specs, design, tasks] = await Promise.all([
63+
fileExists(path.join(changeDir, 'proposal.md')),
64+
dirExists(path.join(changeDir, 'specs')),
65+
fileExists(path.join(changeDir, 'design.md')),
66+
fileExists(path.join(changeDir, 'tasks.md')),
67+
]);
68+
return { proposal, specs, design, tasks };
69+
}
70+
71+
export async function getChangesData(openspecDir: string): Promise<ChangeEntry[]> {
72+
const changesDir = path.join(openspecDir, 'changes');
73+
if (!(await dirExists(changesDir))) {
74+
return [];
75+
}
76+
77+
const entries = await fs.readdir(changesDir, { withFileTypes: true });
78+
const changes: ChangeEntry[] = [];
79+
80+
for (const entry of entries) {
81+
if (!entry.isDirectory() || entry.name === 'archive' || entry.name.startsWith('.')) continue;
82+
83+
const changeDir = path.join(changesDir, entry.name);
84+
const [progress, artifacts] = await Promise.all([
85+
getTaskProgressForChange(changesDir, entry.name),
86+
getArtifacts(changeDir),
87+
]);
88+
89+
let status: ChangeEntry['status'];
90+
if (progress.total === 0) {
91+
status = 'draft';
92+
} else if (progress.completed === progress.total) {
93+
status = 'completed';
94+
} else {
95+
status = 'active';
96+
}
97+
98+
changes.push({ name: entry.name, status, artifacts, progress });
99+
}
100+
101+
changes.sort((a, b) => a.name.localeCompare(b.name));
102+
return changes;
103+
}
104+
105+
export async function getSpecsData(openspecDir: string): Promise<SpecGroup[]> {
106+
const specsDir = path.join(openspecDir, 'specs');
107+
if (!(await dirExists(specsDir))) {
108+
return [];
109+
}
110+
111+
const entries = await fs.readdir(specsDir, { withFileTypes: true });
112+
const specs: SpecEntry[] = [];
113+
114+
for (const entry of entries) {
115+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
116+
117+
const specFile = path.join(specsDir, entry.name, 'spec.md');
118+
if (!(await fileExists(specFile))) continue;
119+
120+
let requirementCount = 0;
121+
try {
122+
const content = await fs.readFile(specFile, 'utf-8');
123+
const parser = new MarkdownParser(content);
124+
const spec = parser.parseSpec(entry.name);
125+
requirementCount = spec.requirements.length;
126+
} catch {
127+
// If spec can't be parsed, include with 0 count
128+
}
129+
130+
specs.push({ name: entry.name, requirementCount });
131+
}
132+
133+
specs.sort((a, b) => a.name.localeCompare(b.name));
134+
135+
// Group by domain prefix (text before first hyphen)
136+
const groupMap = new Map<string, SpecEntry[]>();
137+
for (const spec of specs) {
138+
const hyphenIdx = spec.name.indexOf('-');
139+
const domain = hyphenIdx > 0 ? spec.name.substring(0, hyphenIdx) : spec.name;
140+
const group = groupMap.get(domain);
141+
if (group) {
142+
group.push(spec);
143+
} else {
144+
groupMap.set(domain, [spec]);
145+
}
146+
}
147+
148+
const groups: SpecGroup[] = [];
149+
for (const [domain, domainSpecs] of groupMap) {
150+
groups.push({ domain, specs: domainSpecs });
151+
}
152+
groups.sort((a, b) => a.domain.localeCompare(b.domain));
153+
154+
return groups;
155+
}
156+
157+
export async function getArchiveData(
158+
openspecDir: string,
159+
limit = 50,
160+
offset = 0
161+
): Promise<{ entries: ArchiveEntry[]; total: number }> {
162+
const archiveDir = path.join(openspecDir, 'changes', 'archive');
163+
if (!(await dirExists(archiveDir))) {
164+
return { entries: [], total: 0 };
165+
}
166+
167+
const dirEntries = await fs.readdir(archiveDir, { withFileTypes: true });
168+
const allEntries: ArchiveEntry[] = [];
169+
170+
for (const entry of dirEntries) {
171+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
172+
173+
// Parse date from YYYY-MM-DD-<name> format
174+
const dateMatch = entry.name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
175+
if (dateMatch) {
176+
allEntries.push({
177+
name: entry.name,
178+
date: dateMatch[1],
179+
changeName: dateMatch[2],
180+
});
181+
} else {
182+
allEntries.push({
183+
name: entry.name,
184+
date: '',
185+
changeName: entry.name,
186+
});
187+
}
188+
}
189+
190+
// Sort reverse chronologically (most recent first)
191+
allEntries.sort((a, b) => b.name.localeCompare(a.name));
192+
193+
const total = allEntries.length;
194+
const entries = allEntries.slice(offset, offset + limit);
195+
196+
return { entries, total };
197+
}
198+
199+
export async function getSummary(openspecDir: string): Promise<DashboardSummary> {
200+
const changes = await getChangesData(openspecDir);
201+
const specGroups = await getSpecsData(openspecDir);
202+
const archive = await getArchiveData(openspecDir, 1, 0);
203+
204+
const draft = changes.filter((c) => c.status === 'draft').length;
205+
const active = changes.filter((c) => c.status === 'active').length;
206+
const completed = changes.filter((c) => c.status === 'completed').length;
207+
208+
let totalSpecs = 0;
209+
let totalRequirements = 0;
210+
for (const group of specGroups) {
211+
totalSpecs += group.specs.length;
212+
totalRequirements += group.specs.reduce((sum, s) => sum + s.requirementCount, 0);
213+
}
214+
215+
return {
216+
changes: { draft, active, completed, total: draft + active + completed },
217+
specs: { total: totalSpecs, totalRequirements },
218+
archive: { total: archive.total },
219+
};
220+
}
221+
222+
export async function getArtifactContent(
223+
openspecDir: string,
224+
relativePath: string
225+
): Promise<{ html: string } | { error: string; status: number }> {
226+
// Resolve and verify path stays within openspec directory
227+
const resolvedOpenspec = path.resolve(openspecDir);
228+
const resolvedPath = path.resolve(openspecDir, relativePath);
229+
230+
if (!resolvedPath.startsWith(resolvedOpenspec + path.sep) && resolvedPath !== resolvedOpenspec) {
231+
return { error: 'Forbidden: path outside openspec directory', status: 403 };
232+
}
233+
234+
// Only allow .md and .yaml files
235+
const ext = path.extname(resolvedPath).toLowerCase();
236+
if (ext !== '.md' && ext !== '.yaml' && ext !== '.yml') {
237+
return { error: 'Forbidden: only .md and .yaml files are allowed', status: 403 };
238+
}
239+
240+
try {
241+
const content = await fs.readFile(resolvedPath, 'utf-8');
242+
if (ext === '.md') {
243+
return { html: renderMarkdown(content) };
244+
}
245+
// YAML files: render as code block
246+
return { html: `<pre><code>${content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre>` };
247+
} catch {
248+
return { error: 'File not found', status: 404 };
249+
}
250+
}

src/core/dashboard/index.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import path from 'path';
2+
import { existsSync } from 'fs';
3+
import { startServer, findAvailablePort, openBrowser } from './server.js';
4+
5+
export interface DashboardOptions {
6+
port?: string;
7+
open?: boolean;
8+
}
9+
10+
export class DashboardCommand {
11+
async execute(targetPath: string = '.', options?: DashboardOptions): Promise<void> {
12+
const openspecDir = path.resolve(targetPath, 'openspec');
13+
14+
if (!existsSync(openspecDir)) {
15+
throw new Error('No OpenSpec project found. Run "openspec init" first.');
16+
}
17+
18+
const requestedPort = parseInt(options?.port || '3000', 10);
19+
const shouldOpen = options?.open !== false;
20+
21+
let port: number;
22+
if (options?.port) {
23+
// Explicit port: use it directly, fail if unavailable
24+
port = requestedPort;
25+
} else {
26+
// Default: auto-increment from 3000 to 3010
27+
port = await findAvailablePort(3000, 3010);
28+
}
29+
30+
const server = await startServer({ port, openspecDir, noOpen: !shouldOpen });
31+
32+
const url = `http://127.0.0.1:${port}`;
33+
34+
server.listen(port, '127.0.0.1', () => {
35+
console.log(`\nOpenSpec Dashboard running at ${url}`);
36+
console.log('Press Ctrl+C to stop.\n');
37+
38+
if (shouldOpen) {
39+
openBrowser(url);
40+
}
41+
});
42+
43+
server.on('error', (err: NodeJS.ErrnoException) => {
44+
if (err.code === 'EADDRINUSE') {
45+
throw new Error(
46+
`Port ${port} is already in use. Use --port to specify a different port.`
47+
);
48+
}
49+
throw err;
50+
});
51+
52+
// Graceful shutdown
53+
const shutdown = () => {
54+
console.log('\nShutting down dashboard server...');
55+
server.close(() => {
56+
process.exit(0);
57+
});
58+
};
59+
60+
process.on('SIGINT', shutdown);
61+
process.on('SIGTERM', shutdown);
62+
}
63+
}

0 commit comments

Comments
 (0)