specweave 1.0.259 → 1.0.260
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +39 -25
- package/bin/specweave.js +11 -0
- package/dist/dashboard/assets/index-DdtF4K1G.css +1 -0
- package/dist/dashboard/assets/index-cZA6rz8s.js +11 -0
- package/dist/dashboard/index.html +14 -0
- package/dist/src/cli/commands/dashboard.d.ts +18 -0
- package/dist/src/cli/commands/dashboard.d.ts.map +1 -0
- package/dist/src/cli/commands/dashboard.js +142 -0
- package/dist/src/cli/commands/dashboard.js.map +1 -0
- package/dist/src/core/lazy-loading/llm-plugin-detector.d.ts +9 -4
- package/dist/src/core/lazy-loading/llm-plugin-detector.d.ts.map +1 -1
- package/dist/src/core/lazy-loading/llm-plugin-detector.js +9 -4
- package/dist/src/core/lazy-loading/llm-plugin-detector.js.map +1 -1
- package/dist/src/dashboard/server/command-runner.d.ts +21 -0
- package/dist/src/dashboard/server/command-runner.d.ts.map +1 -0
- package/dist/src/dashboard/server/command-runner.js +92 -0
- package/dist/src/dashboard/server/command-runner.js.map +1 -0
- package/dist/src/dashboard/server/dashboard-server.d.ts +33 -0
- package/dist/src/dashboard/server/dashboard-server.d.ts.map +1 -0
- package/dist/src/dashboard/server/dashboard-server.js +812 -0
- package/dist/src/dashboard/server/dashboard-server.js.map +1 -0
- package/dist/src/dashboard/server/data/activity-stream.d.ts +27 -0
- package/dist/src/dashboard/server/data/activity-stream.d.ts.map +1 -0
- package/dist/src/dashboard/server/data/activity-stream.js +142 -0
- package/dist/src/dashboard/server/data/activity-stream.js.map +1 -0
- package/dist/src/dashboard/server/data/claude-log-parser.d.ts +34 -0
- package/dist/src/dashboard/server/data/claude-log-parser.d.ts.map +1 -0
- package/dist/src/dashboard/server/data/claude-log-parser.js +218 -0
- package/dist/src/dashboard/server/data/claude-log-parser.js.map +1 -0
- package/dist/src/dashboard/server/data/dashboard-data-aggregator.d.ts +35 -0
- package/dist/src/dashboard/server/data/dashboard-data-aggregator.d.ts.map +1 -0
- package/dist/src/dashboard/server/data/dashboard-data-aggregator.js +219 -0
- package/dist/src/dashboard/server/data/dashboard-data-aggregator.js.map +1 -0
- package/dist/src/dashboard/server/data/plugin-scanner.d.ts +35 -0
- package/dist/src/dashboard/server/data/plugin-scanner.d.ts.map +1 -0
- package/dist/src/dashboard/server/data/plugin-scanner.js +96 -0
- package/dist/src/dashboard/server/data/plugin-scanner.js.map +1 -0
- package/dist/src/dashboard/server/data/sync-audit-reader.d.ts +38 -0
- package/dist/src/dashboard/server/data/sync-audit-reader.d.ts.map +1 -0
- package/dist/src/dashboard/server/data/sync-audit-reader.js +94 -0
- package/dist/src/dashboard/server/data/sync-audit-reader.js.map +1 -0
- package/dist/src/dashboard/server/file-watcher.d.ts +19 -0
- package/dist/src/dashboard/server/file-watcher.d.ts.map +1 -0
- package/dist/src/dashboard/server/file-watcher.js +104 -0
- package/dist/src/dashboard/server/file-watcher.js.map +1 -0
- package/dist/src/dashboard/server/router.d.ts +16 -0
- package/dist/src/dashboard/server/router.d.ts.map +1 -0
- package/dist/src/dashboard/server/router.js +110 -0
- package/dist/src/dashboard/server/router.js.map +1 -0
- package/dist/src/dashboard/server/sse-manager.d.ts +25 -0
- package/dist/src/dashboard/server/sse-manager.d.ts.map +1 -0
- package/dist/src/dashboard/server/sse-manager.js +75 -0
- package/dist/src/dashboard/server/sse-manager.js.map +1 -0
- package/dist/src/dashboard/types.d.ts +183 -0
- package/dist/src/dashboard/types.d.ts.map +1 -0
- package/dist/src/dashboard/types.js +2 -0
- package/dist/src/dashboard/types.js.map +1 -0
- package/package.json +12 -2
- package/plugins/specweave/hooks/user-prompt-submit.sh +79 -154
- package/plugins/specweave/skills/do/SKILL.md +31 -1
- package/plugins/specweave/skills/increment/SKILL.md +1 -1
- package/plugins/specweave/skills/increment-planner/SKILL.md +26 -0
- package/plugins/specweave/skills/increment-work-router/SKILL.md +37 -9
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import * as net from 'net';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { Router, sendJson, readBody } from './router.js';
|
|
7
|
+
import { SSEManager } from './sse-manager.js';
|
|
8
|
+
import { FileWatcher } from './file-watcher.js';
|
|
9
|
+
import { CommandRunner } from './command-runner.js';
|
|
10
|
+
import { DashboardDataAggregator } from './data/dashboard-data-aggregator.js';
|
|
11
|
+
import { ClaudeLogParser } from './data/claude-log-parser.js';
|
|
12
|
+
import { PluginScanner } from './data/plugin-scanner.js';
|
|
13
|
+
import { SyncAuditReader } from './data/sync-audit-reader.js';
|
|
14
|
+
import { ActivityStream } from './data/activity-stream.js';
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = path.dirname(__filename);
|
|
17
|
+
const MIME_TYPES = {
|
|
18
|
+
'.html': 'text/html; charset=utf-8',
|
|
19
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
20
|
+
'.css': 'text/css; charset=utf-8',
|
|
21
|
+
'.json': 'application/json',
|
|
22
|
+
'.svg': 'image/svg+xml',
|
|
23
|
+
'.png': 'image/png',
|
|
24
|
+
'.ico': 'image/x-icon',
|
|
25
|
+
'.woff': 'font/woff',
|
|
26
|
+
'.woff2': 'font/woff2',
|
|
27
|
+
};
|
|
28
|
+
export class DashboardServer {
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
this.server = null;
|
|
32
|
+
this.projects = new Map();
|
|
33
|
+
this.sseManager = new SSEManager();
|
|
34
|
+
this.router = new Router();
|
|
35
|
+
// Register initial projects
|
|
36
|
+
for (const root of options.projectRoots) {
|
|
37
|
+
this.addProject(root);
|
|
38
|
+
}
|
|
39
|
+
this.registerRoutes();
|
|
40
|
+
}
|
|
41
|
+
/** Add a new project to the dashboard */
|
|
42
|
+
addProject(projectRoot) {
|
|
43
|
+
const id = pathToProjectId(projectRoot);
|
|
44
|
+
if (this.projects.has(id)) {
|
|
45
|
+
return this.getProjectInfo(id, projectRoot);
|
|
46
|
+
}
|
|
47
|
+
const aggregator = new DashboardDataAggregator(projectRoot);
|
|
48
|
+
const logParser = new ClaudeLogParser(projectRoot);
|
|
49
|
+
const pluginScanner = new PluginScanner(projectRoot);
|
|
50
|
+
const auditReader = new SyncAuditReader(projectRoot);
|
|
51
|
+
const activityStream = new ActivityStream(projectRoot);
|
|
52
|
+
const commandRunner = new CommandRunner(projectRoot, {
|
|
53
|
+
onOutput: (execId, line, stream) => {
|
|
54
|
+
this.sseManager.broadcast('command-output', { projectId: id, executionId: execId, line, stream });
|
|
55
|
+
},
|
|
56
|
+
onComplete: (execId, exitCode) => {
|
|
57
|
+
this.sseManager.broadcast('command-complete', { projectId: id, executionId: execId, exitCode });
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const watcher = new FileWatcher(projectRoot, (event) => {
|
|
61
|
+
this.sseManager.broadcast(event.type, { projectId: id, ...event.data });
|
|
62
|
+
this.sseManager.broadcast('activity', {
|
|
63
|
+
projectId: id,
|
|
64
|
+
category: this.mapEventToCategory(event.type),
|
|
65
|
+
severity: 'info',
|
|
66
|
+
title: `File changed: ${event.type}`,
|
|
67
|
+
...event.data,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
this.projects.set(id, { root: projectRoot, watcher, aggregator, commandRunner, logParser, pluginScanner, auditReader, activityStream });
|
|
71
|
+
return this.getProjectInfo(id, projectRoot);
|
|
72
|
+
}
|
|
73
|
+
/** Remove a project from the dashboard */
|
|
74
|
+
removeProject(projectId) {
|
|
75
|
+
const project = this.projects.get(projectId);
|
|
76
|
+
if (project) {
|
|
77
|
+
project.watcher.close();
|
|
78
|
+
project.commandRunner.cancel();
|
|
79
|
+
this.projects.delete(projectId);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
getProjectInfo(id, projectRoot) {
|
|
83
|
+
let name = path.basename(projectRoot);
|
|
84
|
+
try {
|
|
85
|
+
const config = JSON.parse(fs.readFileSync(path.join(projectRoot, '.specweave/config.json'), 'utf-8'));
|
|
86
|
+
if (config?.project?.name)
|
|
87
|
+
name = config.project.name;
|
|
88
|
+
}
|
|
89
|
+
catch { /* use dir name */ }
|
|
90
|
+
return { id, path: projectRoot, name, hasSpecweave: true };
|
|
91
|
+
}
|
|
92
|
+
async start() {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
95
|
+
this.server.on('error', (err) => {
|
|
96
|
+
reject(err);
|
|
97
|
+
});
|
|
98
|
+
this.server.listen(this.options.port, () => {
|
|
99
|
+
const url = `http://localhost:${this.options.port}`;
|
|
100
|
+
resolve({
|
|
101
|
+
url,
|
|
102
|
+
port: this.options.port,
|
|
103
|
+
stop: () => this.stop(),
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
async stop() {
|
|
109
|
+
for (const project of this.projects.values()) {
|
|
110
|
+
project.watcher.close();
|
|
111
|
+
project.commandRunner.cancel();
|
|
112
|
+
}
|
|
113
|
+
this.sseManager.closeAll();
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
if (this.server) {
|
|
116
|
+
this.server.close(() => resolve());
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
resolve();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
async handleRequest(req, res) {
|
|
124
|
+
const url = req.url || '/';
|
|
125
|
+
// CORS preflight — only allow localhost origins
|
|
126
|
+
if (req.method === 'OPTIONS') {
|
|
127
|
+
const origin = req.headers.origin || '';
|
|
128
|
+
const headers = {
|
|
129
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
130
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
131
|
+
'Vary': 'Origin',
|
|
132
|
+
};
|
|
133
|
+
if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
|
|
134
|
+
headers['Access-Control-Allow-Origin'] = origin;
|
|
135
|
+
}
|
|
136
|
+
res.writeHead(204, headers);
|
|
137
|
+
res.end();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// SSE endpoint
|
|
141
|
+
if (url.startsWith('/api/events') && req.method === 'GET') {
|
|
142
|
+
this.sseManager.addConnection(res);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// API routes
|
|
146
|
+
if (url.startsWith('/api/')) {
|
|
147
|
+
const handled = await this.router.handle(req, res);
|
|
148
|
+
if (!handled) {
|
|
149
|
+
sendJson(res, { ok: false, error: 'Not found' }, 404);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Static files
|
|
154
|
+
await this.serveStaticFile(req, res, url);
|
|
155
|
+
}
|
|
156
|
+
/** Resolve project from query string ?project=<id> */
|
|
157
|
+
resolveProject(req) {
|
|
158
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
159
|
+
const projectId = url.searchParams.get('project');
|
|
160
|
+
const pick = (id, p) => ({
|
|
161
|
+
aggregator: p.aggregator,
|
|
162
|
+
commandRunner: p.commandRunner,
|
|
163
|
+
logParser: p.logParser,
|
|
164
|
+
pluginScanner: p.pluginScanner,
|
|
165
|
+
auditReader: p.auditReader,
|
|
166
|
+
activityStream: p.activityStream,
|
|
167
|
+
id,
|
|
168
|
+
root: p.root,
|
|
169
|
+
});
|
|
170
|
+
if (projectId) {
|
|
171
|
+
const project = this.projects.get(projectId);
|
|
172
|
+
if (project)
|
|
173
|
+
return pick(projectId, project);
|
|
174
|
+
}
|
|
175
|
+
const first = this.projects.entries().next().value;
|
|
176
|
+
if (first) {
|
|
177
|
+
const [id, project] = first;
|
|
178
|
+
return pick(id, project);
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
registerRoutes() {
|
|
183
|
+
// === Global routes (not project-scoped) ===
|
|
184
|
+
// Health
|
|
185
|
+
this.router.get('/api/health', async (_req, res) => {
|
|
186
|
+
sendJson(res, {
|
|
187
|
+
ok: true,
|
|
188
|
+
uptime: process.uptime(),
|
|
189
|
+
connections: this.sseManager.connectionCount,
|
|
190
|
+
projectCount: this.projects.size,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
// Project management
|
|
194
|
+
this.router.get('/api/projects', async (_req, res) => {
|
|
195
|
+
const projects = [];
|
|
196
|
+
for (const [id, proj] of this.projects) {
|
|
197
|
+
projects.push(this.getProjectInfo(id, proj.root));
|
|
198
|
+
}
|
|
199
|
+
sendJson(res, { ok: true, data: projects });
|
|
200
|
+
});
|
|
201
|
+
this.router.post('/api/projects', async (req, res) => {
|
|
202
|
+
const body = await readBody(req);
|
|
203
|
+
if (!body?.path || typeof body.path !== 'string') {
|
|
204
|
+
sendJson(res, { ok: false, error: 'Missing path' }, 400);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// Validate: must be an absolute path
|
|
208
|
+
const resolvedPath = path.resolve(body.path);
|
|
209
|
+
if (!path.isAbsolute(resolvedPath)) {
|
|
210
|
+
sendJson(res, { ok: false, error: 'Path must be absolute' }, 400);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const specweavePath = path.join(resolvedPath, '.specweave');
|
|
214
|
+
if (!fs.existsSync(specweavePath)) {
|
|
215
|
+
sendJson(res, { ok: false, error: 'Not a SpecWeave project' }, 400);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const info = this.addProject(resolvedPath);
|
|
219
|
+
sendJson(res, { ok: true, data: info });
|
|
220
|
+
});
|
|
221
|
+
this.router.delete('/api/projects/:id', async (_req, res, params) => {
|
|
222
|
+
this.removeProject(params.id);
|
|
223
|
+
sendJson(res, { ok: true });
|
|
224
|
+
});
|
|
225
|
+
// === Project-scoped routes (use ?project=<id> query param) ===
|
|
226
|
+
// Overview
|
|
227
|
+
this.router.get('/api/overview', async (req, res) => {
|
|
228
|
+
const project = this.resolveProject(req);
|
|
229
|
+
if (!project)
|
|
230
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
231
|
+
const data = await project.aggregator.getOverview();
|
|
232
|
+
sendJson(res, { ok: true, data });
|
|
233
|
+
});
|
|
234
|
+
// Increments
|
|
235
|
+
this.router.get('/api/increments', async (req, res) => {
|
|
236
|
+
const project = this.resolveProject(req);
|
|
237
|
+
if (!project)
|
|
238
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
239
|
+
const data = await project.aggregator.getIncrements();
|
|
240
|
+
sendJson(res, { ok: true, data });
|
|
241
|
+
});
|
|
242
|
+
// Analytics
|
|
243
|
+
this.router.get('/api/analytics/summary', async (req, res) => {
|
|
244
|
+
const project = this.resolveProject(req);
|
|
245
|
+
if (!project)
|
|
246
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
247
|
+
const data = await project.aggregator.getAnalyticsSummary();
|
|
248
|
+
sendJson(res, { ok: true, data });
|
|
249
|
+
});
|
|
250
|
+
this.router.get('/api/analytics/skills', async (req, res) => {
|
|
251
|
+
const project = this.resolveProject(req);
|
|
252
|
+
if (!project)
|
|
253
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
254
|
+
const data = await project.aggregator.getSkillUsage();
|
|
255
|
+
sendJson(res, { ok: true, data });
|
|
256
|
+
});
|
|
257
|
+
// Costs
|
|
258
|
+
this.router.get('/api/costs/summary', async (req, res) => {
|
|
259
|
+
const project = this.resolveProject(req);
|
|
260
|
+
if (!project)
|
|
261
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
262
|
+
const data = await project.aggregator.getCostsSummary();
|
|
263
|
+
sendJson(res, { ok: true, data });
|
|
264
|
+
});
|
|
265
|
+
// Sync
|
|
266
|
+
this.router.get('/api/sync/status', async (req, res) => {
|
|
267
|
+
const project = this.resolveProject(req);
|
|
268
|
+
if (!project)
|
|
269
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
270
|
+
const data = await project.aggregator.getSyncStatus();
|
|
271
|
+
sendJson(res, { ok: true, data });
|
|
272
|
+
});
|
|
273
|
+
// Notifications
|
|
274
|
+
this.router.get('/api/notifications', async (req, res) => {
|
|
275
|
+
const project = this.resolveProject(req);
|
|
276
|
+
if (!project)
|
|
277
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
278
|
+
const data = await project.aggregator.getNotifications();
|
|
279
|
+
sendJson(res, { ok: true, data });
|
|
280
|
+
});
|
|
281
|
+
// Config
|
|
282
|
+
this.router.get('/api/config', async (req, res) => {
|
|
283
|
+
const project = this.resolveProject(req);
|
|
284
|
+
if (!project)
|
|
285
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
286
|
+
const data = await project.aggregator.getConfig();
|
|
287
|
+
sendJson(res, { ok: true, data });
|
|
288
|
+
});
|
|
289
|
+
// Plugins & LSP
|
|
290
|
+
this.router.get('/api/plugins', async (req, res) => {
|
|
291
|
+
const project = this.resolveProject(req);
|
|
292
|
+
if (!project)
|
|
293
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
294
|
+
const data = await project.aggregator.getPlugins();
|
|
295
|
+
sendJson(res, { ok: true, data });
|
|
296
|
+
});
|
|
297
|
+
this.router.get('/api/lsp', async (req, res) => {
|
|
298
|
+
const project = this.resolveProject(req);
|
|
299
|
+
if (!project)
|
|
300
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
301
|
+
const data = await project.aggregator.getLspStatus();
|
|
302
|
+
sendJson(res, { ok: true, data });
|
|
303
|
+
});
|
|
304
|
+
// Commands
|
|
305
|
+
this.router.post('/api/commands/:name', async (req, res, params) => {
|
|
306
|
+
const project = this.resolveProject(req);
|
|
307
|
+
if (!project)
|
|
308
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
309
|
+
const execution = project.commandRunner.execute(params.name);
|
|
310
|
+
if (!execution) {
|
|
311
|
+
sendJson(res, { ok: false, error: 'Command not available or already running' }, 400);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
sendJson(res, { ok: true, data: execution });
|
|
315
|
+
});
|
|
316
|
+
this.router.get('/api/commands/active', async (req, res) => {
|
|
317
|
+
const project = this.resolveProject(req);
|
|
318
|
+
if (!project)
|
|
319
|
+
return sendJson(res, { ok: true, data: null });
|
|
320
|
+
const active = project.commandRunner.getActive();
|
|
321
|
+
sendJson(res, { ok: true, data: active });
|
|
322
|
+
});
|
|
323
|
+
this.router.get('/api/commands/allowed', async (_req, res) => {
|
|
324
|
+
sendJson(res, { ok: true, data: CommandRunner.getAllowedCommands() });
|
|
325
|
+
});
|
|
326
|
+
// === Config Validate ===
|
|
327
|
+
this.router.post('/api/config/validate', async (req, res) => {
|
|
328
|
+
const project = this.resolveProject(req);
|
|
329
|
+
if (!project)
|
|
330
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
331
|
+
try {
|
|
332
|
+
const body = await readBody(req);
|
|
333
|
+
if (!body || typeof body !== 'object')
|
|
334
|
+
return sendJson(res, { ok: false, error: 'Invalid body' }, 400);
|
|
335
|
+
// Read schema expectations from existing config structure
|
|
336
|
+
const currentConfig = await project.aggregator.getConfig();
|
|
337
|
+
const errors = [];
|
|
338
|
+
// Validate known typed fields
|
|
339
|
+
if (body.testing && typeof body.testing === 'object') {
|
|
340
|
+
const testing = body.testing;
|
|
341
|
+
if (testing.defaultTestMode && !['test-after', 'TDD', 'coverage-only'].includes(String(testing.defaultTestMode))) {
|
|
342
|
+
errors.push('testing.defaultTestMode must be one of: test-after, TDD, coverage-only');
|
|
343
|
+
}
|
|
344
|
+
if (testing.tddEnforcement && !['strict', 'warn', 'off'].includes(String(testing.tddEnforcement))) {
|
|
345
|
+
errors.push('testing.tddEnforcement must be one of: strict, warn, off');
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (body.limits && typeof body.limits === 'object') {
|
|
349
|
+
const limits = body.limits;
|
|
350
|
+
if (limits.maxActiveIncrements != null && (typeof limits.maxActiveIncrements !== 'number' || limits.maxActiveIncrements < 1)) {
|
|
351
|
+
errors.push('limits.maxActiveIncrements must be a positive number');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
sendJson(res, { ok: errors.length === 0, errors, currentConfig });
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
sendJson(res, { ok: false, error: 'Validation failed' }, 500);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
// === Command Cancel ===
|
|
361
|
+
this.router.post('/api/commands/:id/cancel', async (req, res) => {
|
|
362
|
+
const project = this.resolveProject(req);
|
|
363
|
+
if (!project)
|
|
364
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
365
|
+
const cancelled = project.commandRunner.cancel();
|
|
366
|
+
if (!cancelled) {
|
|
367
|
+
sendJson(res, { ok: false, error: 'No running command to cancel' }, 400);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
sendJson(res, { ok: true });
|
|
371
|
+
});
|
|
372
|
+
// === Analytics Events ===
|
|
373
|
+
this.router.get('/api/analytics/events', async (req, res) => {
|
|
374
|
+
const project = this.resolveProject(req);
|
|
375
|
+
if (!project)
|
|
376
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
377
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
378
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '200', 10) || 200, 1000);
|
|
379
|
+
const type = url.searchParams.get('type') || undefined;
|
|
380
|
+
const since = url.searchParams.get('since') || undefined;
|
|
381
|
+
const eventsPath = path.join(project.root, '.specweave/state/analytics/events.jsonl');
|
|
382
|
+
const events = [];
|
|
383
|
+
try {
|
|
384
|
+
if (fs.existsSync(eventsPath)) {
|
|
385
|
+
const lines = fs.readFileSync(eventsPath, 'utf-8').split('\n').filter(Boolean);
|
|
386
|
+
// Read from end for efficiency
|
|
387
|
+
for (let i = lines.length - 1; i >= 0 && events.length < limit; i--) {
|
|
388
|
+
try {
|
|
389
|
+
const event = JSON.parse(lines[i]);
|
|
390
|
+
if (type && event.type !== type)
|
|
391
|
+
continue;
|
|
392
|
+
if (since && event.timestamp && event.timestamp < since)
|
|
393
|
+
break;
|
|
394
|
+
events.push(event);
|
|
395
|
+
}
|
|
396
|
+
catch { /* skip bad lines */ }
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
catch { /* */ }
|
|
401
|
+
sendJson(res, { ok: true, data: events });
|
|
402
|
+
});
|
|
403
|
+
// === Phase 2: Error Tracing ===
|
|
404
|
+
this.router.get('/api/errors/recent', async (req, res) => {
|
|
405
|
+
const project = this.resolveProject(req);
|
|
406
|
+
if (!project)
|
|
407
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
408
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
409
|
+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
410
|
+
const data = await project.logParser.getRecentErrors(limit);
|
|
411
|
+
sendJson(res, { ok: true, data });
|
|
412
|
+
});
|
|
413
|
+
this.router.get('/api/errors/groups', async (req, res) => {
|
|
414
|
+
const project = this.resolveProject(req);
|
|
415
|
+
if (!project)
|
|
416
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
417
|
+
const data = await project.logParser.getErrorGroups();
|
|
418
|
+
sendJson(res, { ok: true, data });
|
|
419
|
+
});
|
|
420
|
+
this.router.get('/api/errors/sessions', async (req, res) => {
|
|
421
|
+
const project = this.resolveProject(req);
|
|
422
|
+
if (!project)
|
|
423
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
424
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
425
|
+
const limit = parseInt(url.searchParams.get('limit') || '30', 10);
|
|
426
|
+
const data = await project.logParser.getSessionSummaries(limit);
|
|
427
|
+
sendJson(res, { ok: true, data });
|
|
428
|
+
});
|
|
429
|
+
this.router.get('/api/errors/sessions/:sessionId', async (req, res, params) => {
|
|
430
|
+
const project = this.resolveProject(req);
|
|
431
|
+
if (!project)
|
|
432
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
433
|
+
const data = await project.logParser.getSessionDetail(params.sessionId);
|
|
434
|
+
if (!data)
|
|
435
|
+
return sendJson(res, { ok: false, error: 'Session not found' }, 404);
|
|
436
|
+
sendJson(res, { ok: true, data });
|
|
437
|
+
});
|
|
438
|
+
// === Phase 2: Sync Audit ===
|
|
439
|
+
this.router.get('/api/sync/audit', async (req, res) => {
|
|
440
|
+
const project = this.resolveProject(req);
|
|
441
|
+
if (!project)
|
|
442
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
443
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
444
|
+
const data = await project.auditReader.getRecentEntries({
|
|
445
|
+
limit: parseInt(url.searchParams.get('limit') || '100', 10),
|
|
446
|
+
platform: url.searchParams.get('platform') || undefined,
|
|
447
|
+
result: url.searchParams.get('result') || undefined,
|
|
448
|
+
since: url.searchParams.get('since') || undefined,
|
|
449
|
+
});
|
|
450
|
+
sendJson(res, { ok: true, data });
|
|
451
|
+
});
|
|
452
|
+
this.router.get('/api/sync/audit/summary', async (req, res) => {
|
|
453
|
+
const project = this.resolveProject(req);
|
|
454
|
+
if (!project)
|
|
455
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
456
|
+
const data = await project.auditReader.getSummary();
|
|
457
|
+
sendJson(res, { ok: true, data });
|
|
458
|
+
});
|
|
459
|
+
// === Phase 2: Enhanced Plugins ===
|
|
460
|
+
this.router.get('/api/plugins/full', async (req, res) => {
|
|
461
|
+
const project = this.resolveProject(req);
|
|
462
|
+
if (!project)
|
|
463
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
464
|
+
const data = project.pluginScanner.getFullPluginData();
|
|
465
|
+
sendJson(res, { ok: true, data });
|
|
466
|
+
});
|
|
467
|
+
// === Phase 3: Activity Stream ===
|
|
468
|
+
this.router.get('/api/activity/stream', async (req, res) => {
|
|
469
|
+
const project = this.resolveProject(req);
|
|
470
|
+
if (!project)
|
|
471
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
472
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
473
|
+
const categoriesParam = url.searchParams.get('categories');
|
|
474
|
+
const data = await project.activityStream.getRecentActivity({
|
|
475
|
+
limit: parseInt(url.searchParams.get('limit') || '100', 10),
|
|
476
|
+
categories: categoriesParam ? categoriesParam.split(',') : undefined,
|
|
477
|
+
severity: url.searchParams.get('severity') || undefined,
|
|
478
|
+
since: url.searchParams.get('since') || undefined,
|
|
479
|
+
});
|
|
480
|
+
sendJson(res, { ok: true, data });
|
|
481
|
+
});
|
|
482
|
+
// === Phase 3: Notification Dismiss ===
|
|
483
|
+
this.router.post('/api/notifications/:id/dismiss', async (req, res, params) => {
|
|
484
|
+
const project = this.resolveProject(req);
|
|
485
|
+
if (!project)
|
|
486
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
487
|
+
const notifPath = path.join(project.root, '.specweave/state/notifications.json');
|
|
488
|
+
try {
|
|
489
|
+
if (!fs.existsSync(notifPath))
|
|
490
|
+
return sendJson(res, { ok: false, error: 'No notifications file' }, 404);
|
|
491
|
+
const data = JSON.parse(fs.readFileSync(notifPath, 'utf-8'));
|
|
492
|
+
const notifications = data?.notifications || data || [];
|
|
493
|
+
if (!Array.isArray(notifications))
|
|
494
|
+
return sendJson(res, { ok: false, error: 'Invalid format' }, 400);
|
|
495
|
+
const notif = notifications.find((n) => n.id === params.id);
|
|
496
|
+
if (!notif)
|
|
497
|
+
return sendJson(res, { ok: false, error: 'Notification not found' }, 404);
|
|
498
|
+
notif.dismissedAt = new Date().toISOString();
|
|
499
|
+
if (data?.notifications) {
|
|
500
|
+
data.notifications = notifications;
|
|
501
|
+
}
|
|
502
|
+
fs.writeFileSync(notifPath, JSON.stringify(data?.notifications ? data : notifications, null, 2));
|
|
503
|
+
sendJson(res, { ok: true });
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
sendJson(res, { ok: false, error: 'Failed to dismiss' }, 500);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
// === Phase 2: Cost Sessions ===
|
|
510
|
+
this.router.get('/api/costs/sessions', async (req, res) => {
|
|
511
|
+
const project = this.resolveProject(req);
|
|
512
|
+
if (!project)
|
|
513
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
514
|
+
const costs = await project.aggregator.getCostsSummary();
|
|
515
|
+
const sessions = costs?.sessions || [];
|
|
516
|
+
sendJson(res, { ok: true, data: sessions });
|
|
517
|
+
});
|
|
518
|
+
// === Phase 4: Config Write ===
|
|
519
|
+
this.router.put('/api/config', async (req, res) => {
|
|
520
|
+
const project = this.resolveProject(req);
|
|
521
|
+
if (!project)
|
|
522
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
523
|
+
const configPath = path.join(project.root, '.specweave/config.json');
|
|
524
|
+
try {
|
|
525
|
+
const body = await readBody(req);
|
|
526
|
+
if (!body || typeof body !== 'object')
|
|
527
|
+
return sendJson(res, { ok: false, error: 'Invalid body' }, 400);
|
|
528
|
+
// Read current config, merge with updates
|
|
529
|
+
let current = {};
|
|
530
|
+
if (fs.existsSync(configPath)) {
|
|
531
|
+
current = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
532
|
+
}
|
|
533
|
+
const merged = deepMerge(current, body);
|
|
534
|
+
fs.writeFileSync(configPath, JSON.stringify(merged, null, 2));
|
|
535
|
+
sendJson(res, { ok: true, data: merged });
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
sendJson(res, { ok: false, error: 'Failed to save config' }, 500);
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
// === Phase 4: Increment Detail ===
|
|
542
|
+
this.router.get('/api/increments/:id', async (req, res, params) => {
|
|
543
|
+
const project = this.resolveProject(req);
|
|
544
|
+
if (!project)
|
|
545
|
+
return sendJson(res, { ok: false, error: 'No projects registered' }, 404);
|
|
546
|
+
const data = await getIncrementDetail(project.root, params.id);
|
|
547
|
+
if (!data)
|
|
548
|
+
return sendJson(res, { ok: false, error: 'Increment not found' }, 404);
|
|
549
|
+
sendJson(res, { ok: true, data });
|
|
550
|
+
});
|
|
551
|
+
// === Phase 4: Repos ===
|
|
552
|
+
this.router.get('/api/repos', async (req, res) => {
|
|
553
|
+
const project = this.resolveProject(req);
|
|
554
|
+
if (!project)
|
|
555
|
+
return sendJson(res, { ok: true, data: [] });
|
|
556
|
+
const data = scanRepositories(project.root);
|
|
557
|
+
sendJson(res, { ok: true, data });
|
|
558
|
+
});
|
|
559
|
+
// === Phase 4: Services ===
|
|
560
|
+
this.router.get('/api/services', async (req, res) => {
|
|
561
|
+
const project = this.resolveProject(req);
|
|
562
|
+
if (!project)
|
|
563
|
+
return sendJson(res, { ok: true, data: [] });
|
|
564
|
+
const config = await project.aggregator.getConfig();
|
|
565
|
+
const docsPort = config?.documentation?.previewPort ?? 3000;
|
|
566
|
+
const services = [
|
|
567
|
+
{ name: 'Dashboard Server', status: 'running', detail: `http://localhost:${this.options.port}`, port: this.options.port },
|
|
568
|
+
{ name: 'Docs Preview', status: await checkPort(docsPort) ? 'running' : 'stopped', detail: `http://localhost:${docsPort}`, port: docsPort },
|
|
569
|
+
];
|
|
570
|
+
sendJson(res, { ok: true, data: services });
|
|
571
|
+
});
|
|
572
|
+
// Links (derived from config)
|
|
573
|
+
this.router.get('/api/links', async (req, res) => {
|
|
574
|
+
const project = this.resolveProject(req);
|
|
575
|
+
if (!project)
|
|
576
|
+
return sendJson(res, { ok: true, data: [] });
|
|
577
|
+
const config = await project.aggregator.getConfig();
|
|
578
|
+
const links = [
|
|
579
|
+
{ name: 'Public Docs', url: 'https://spec-weave.com', type: 'docs' },
|
|
580
|
+
];
|
|
581
|
+
if (config?.sync?.github?.owner && config?.sync?.github?.repo) {
|
|
582
|
+
links.push({
|
|
583
|
+
name: 'GitHub Repository',
|
|
584
|
+
url: `https://github.com/${config.sync.github.owner}/${config.sync.github.repo}`,
|
|
585
|
+
type: 'github',
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
if (config?.sync?.jira?.domain) {
|
|
589
|
+
links.push({
|
|
590
|
+
name: 'JIRA Board',
|
|
591
|
+
url: `https://${config.sync.jira.domain}/browse/${config.sync.jira.projectKey || ''}`,
|
|
592
|
+
type: 'jira',
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
if (config?.sync?.ado?.organization) {
|
|
596
|
+
links.push({
|
|
597
|
+
name: 'Azure DevOps',
|
|
598
|
+
url: `https://dev.azure.com/${config.sync.ado.organization}/${config.sync.ado.project || ''}`,
|
|
599
|
+
type: 'ado',
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
sendJson(res, { ok: true, data: links });
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
async serveStaticFile(_req, res, url) {
|
|
606
|
+
const dashboardDir = path.resolve(__dirname, '../../../dashboard');
|
|
607
|
+
// Strip query params for static file resolution
|
|
608
|
+
const cleanUrl = url.split('?')[0];
|
|
609
|
+
let filePath = path.resolve(dashboardDir, cleanUrl === '/' ? 'index.html' : '.' + cleanUrl);
|
|
610
|
+
// SECURITY: prevent path traversal (e.g. /../../../etc/passwd)
|
|
611
|
+
if (!filePath.startsWith(dashboardDir + path.sep) && filePath !== dashboardDir) {
|
|
612
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
613
|
+
res.end('Forbidden');
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
// SPA fallback: serve index.html for all non-file routes
|
|
617
|
+
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
|
618
|
+
filePath = path.join(dashboardDir, 'index.html');
|
|
619
|
+
}
|
|
620
|
+
if (!fs.existsSync(filePath)) {
|
|
621
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
622
|
+
res.end('Dashboard not built. Run: npm run build:dashboard');
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const ext = path.extname(filePath);
|
|
626
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
627
|
+
res.writeHead(200, {
|
|
628
|
+
'Content-Type': contentType,
|
|
629
|
+
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable',
|
|
630
|
+
});
|
|
631
|
+
const stream = fs.createReadStream(filePath);
|
|
632
|
+
stream.on('error', () => {
|
|
633
|
+
if (!res.headersSent) {
|
|
634
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
635
|
+
}
|
|
636
|
+
res.end('Internal server error');
|
|
637
|
+
});
|
|
638
|
+
stream.pipe(res);
|
|
639
|
+
}
|
|
640
|
+
mapEventToCategory(eventType) {
|
|
641
|
+
const map = {
|
|
642
|
+
'increment-update': 'command',
|
|
643
|
+
'analytics-event': 'command',
|
|
644
|
+
'cost-update': 'cost',
|
|
645
|
+
'notification': 'notification',
|
|
646
|
+
'sync-update': 'sync',
|
|
647
|
+
'sync-audit': 'sync',
|
|
648
|
+
'config-changed': 'command',
|
|
649
|
+
'error-detected': 'error',
|
|
650
|
+
};
|
|
651
|
+
return map[eventType] || 'command';
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
function pathToProjectId(p) {
|
|
655
|
+
return p.replace(/^\//, '').replace(/\//g, '-');
|
|
656
|
+
}
|
|
657
|
+
/** Deep merge objects (target mutated). Filters prototype pollution keys. */
|
|
658
|
+
function deepMerge(target, source) {
|
|
659
|
+
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
660
|
+
for (const key of Object.keys(source)) {
|
|
661
|
+
if (DANGEROUS_KEYS.has(key))
|
|
662
|
+
continue;
|
|
663
|
+
const sv = source[key];
|
|
664
|
+
const tv = target[key];
|
|
665
|
+
if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
|
|
666
|
+
target[key] = deepMerge(tv, sv);
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
target[key] = sv;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return target;
|
|
673
|
+
}
|
|
674
|
+
/** Get increment detail by reading metadata.json, tasks.md, and spec.md */
|
|
675
|
+
async function getIncrementDetail(projectRoot, incrementId) {
|
|
676
|
+
// Search for increment directory
|
|
677
|
+
const incrementsDir = path.join(projectRoot, '.specweave/increments');
|
|
678
|
+
if (!fs.existsSync(incrementsDir))
|
|
679
|
+
return null;
|
|
680
|
+
let incDir = null;
|
|
681
|
+
try {
|
|
682
|
+
const dirs = fs.readdirSync(incrementsDir, { withFileTypes: true });
|
|
683
|
+
for (const d of dirs) {
|
|
684
|
+
if (!d.isDirectory())
|
|
685
|
+
continue;
|
|
686
|
+
if (d.name === incrementId || d.name.startsWith(incrementId)) {
|
|
687
|
+
incDir = path.join(incrementsDir, d.name);
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
if (!incDir)
|
|
696
|
+
return null;
|
|
697
|
+
// Read metadata
|
|
698
|
+
let metadata = {};
|
|
699
|
+
const metadataPath = path.join(incDir, 'metadata.json');
|
|
700
|
+
if (fs.existsSync(metadataPath)) {
|
|
701
|
+
try {
|
|
702
|
+
metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
|
703
|
+
}
|
|
704
|
+
catch { /* */ }
|
|
705
|
+
}
|
|
706
|
+
// Parse tasks from tasks.md
|
|
707
|
+
const tasks = [];
|
|
708
|
+
const tasksPath = path.join(incDir, 'tasks.md');
|
|
709
|
+
if (fs.existsSync(tasksPath)) {
|
|
710
|
+
const content = fs.readFileSync(tasksPath, 'utf-8');
|
|
711
|
+
const taskRegex = /###\s+(T-\d+):\s*(.+)/g;
|
|
712
|
+
let match;
|
|
713
|
+
while ((match = taskRegex.exec(content)) !== null) {
|
|
714
|
+
const id = match[1];
|
|
715
|
+
const title = match[2].trim();
|
|
716
|
+
// Find status: look for [x] or [ ] nearby
|
|
717
|
+
const afterMatch = content.slice(match.index, match.index + 500);
|
|
718
|
+
const statusMatch = afterMatch.match(/\*\*Status\*\*:\s*\[([ x])\]/);
|
|
719
|
+
const usMatch = afterMatch.match(/\*\*User Story\*\*:\s*(US-\d+)/);
|
|
720
|
+
const acMatch = afterMatch.match(/\*\*(?:Satisfies ACs?|AC)\*\*:\s*(AC-[^\n]+)/);
|
|
721
|
+
tasks.push({
|
|
722
|
+
id,
|
|
723
|
+
title,
|
|
724
|
+
status: statusMatch?.[1] === 'x' ? 'completed' : 'pending',
|
|
725
|
+
userStory: usMatch?.[1],
|
|
726
|
+
acs: acMatch?.[1]?.split(',').map(s => s.trim()),
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
// Parse ACs from spec.md
|
|
731
|
+
const acs = [];
|
|
732
|
+
const specPath = path.join(incDir, 'spec.md');
|
|
733
|
+
if (fs.existsSync(specPath)) {
|
|
734
|
+
const content = fs.readFileSync(specPath, 'utf-8');
|
|
735
|
+
const acRegex = /- \[([ x])\] \*\*(AC-[^*]+)\*\*:\s*(.+)/g;
|
|
736
|
+
let match;
|
|
737
|
+
while ((match = acRegex.exec(content)) !== null) {
|
|
738
|
+
acs.push({
|
|
739
|
+
id: match[2].trim(),
|
|
740
|
+
text: match[3].trim(),
|
|
741
|
+
completed: match[1] === 'x',
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const taskSummary = {
|
|
746
|
+
total: tasks.length,
|
|
747
|
+
completed: tasks.filter(t => t.status === 'completed').length,
|
|
748
|
+
pending: tasks.filter(t => t.status === 'pending').length,
|
|
749
|
+
};
|
|
750
|
+
const acSummary = {
|
|
751
|
+
total: acs.length,
|
|
752
|
+
completed: acs.filter(a => a.completed).length,
|
|
753
|
+
};
|
|
754
|
+
return {
|
|
755
|
+
id: incrementId,
|
|
756
|
+
metadata,
|
|
757
|
+
tasks,
|
|
758
|
+
taskSummary,
|
|
759
|
+
acs,
|
|
760
|
+
acSummary,
|
|
761
|
+
dirName: path.basename(incDir),
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
/** Scan repositories/ directory for cloned repos */
|
|
765
|
+
function scanRepositories(projectRoot) {
|
|
766
|
+
const reposDir = path.join(projectRoot, 'repositories');
|
|
767
|
+
if (!fs.existsSync(reposDir))
|
|
768
|
+
return [];
|
|
769
|
+
const repos = [];
|
|
770
|
+
try {
|
|
771
|
+
const orgs = fs.readdirSync(reposDir, { withFileTypes: true });
|
|
772
|
+
for (const org of orgs) {
|
|
773
|
+
if (!org.isDirectory())
|
|
774
|
+
continue;
|
|
775
|
+
const orgDir = path.join(reposDir, org.name);
|
|
776
|
+
const repoEntries = fs.readdirSync(orgDir, { withFileTypes: true });
|
|
777
|
+
for (const repo of repoEntries) {
|
|
778
|
+
if (!repo.isDirectory())
|
|
779
|
+
continue;
|
|
780
|
+
const repoPath = path.join(orgDir, repo.name);
|
|
781
|
+
const hasSpecweave = fs.existsSync(path.join(repoPath, '.specweave'));
|
|
782
|
+
let lastModified = '';
|
|
783
|
+
try {
|
|
784
|
+
const stat = fs.statSync(repoPath);
|
|
785
|
+
lastModified = stat.mtime.toISOString();
|
|
786
|
+
}
|
|
787
|
+
catch { /* */ }
|
|
788
|
+
repos.push({
|
|
789
|
+
name: repo.name,
|
|
790
|
+
org: org.name,
|
|
791
|
+
path: repoPath,
|
|
792
|
+
hasSpecweave,
|
|
793
|
+
lastModified,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
catch { /* */ }
|
|
799
|
+
return repos.sort((a, b) => a.name.localeCompare(b.name));
|
|
800
|
+
}
|
|
801
|
+
/** Check if a port is in use (service running) */
|
|
802
|
+
async function checkPort(port) {
|
|
803
|
+
return new Promise((resolve) => {
|
|
804
|
+
const socket = new net.Socket();
|
|
805
|
+
socket.setTimeout(500);
|
|
806
|
+
socket.once('connect', () => { socket.destroy(); resolve(true); });
|
|
807
|
+
socket.once('timeout', () => { socket.destroy(); resolve(false); });
|
|
808
|
+
socket.once('error', () => { socket.destroy(); resolve(false); });
|
|
809
|
+
socket.connect(port, '127.0.0.1');
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
//# sourceMappingURL=dashboard-server.js.map
|