plankit-cli 1.5.0 → 1.6.0
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/README.md +22 -0
- package/package.json +1 -1
- package/src/cli.js +57 -0
- package/src/server/clientHtml.js +674 -0
- package/src/server/server.js +155 -0
package/README.md
CHANGED
|
@@ -215,6 +215,28 @@ plankit ui --no-tui
|
|
|
215
215
|
|
|
216
216
|
---
|
|
217
217
|
|
|
218
|
+
## Visual web dashboard companion (`plankit serve`)
|
|
219
|
+
|
|
220
|
+
Launch an interactive, local browser companion on your choice of port:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
# Launch on default port 4200 (prompts if run interactively without flag)
|
|
224
|
+
plankit serve
|
|
225
|
+
|
|
226
|
+
# Specify custom port
|
|
227
|
+
plankit serve --port 5000
|
|
228
|
+
plankit web -p 8080
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Features:
|
|
232
|
+
- **Feature Matrix & Progress Board**: Visual cards with completion percentages and modal document viewers for specs and outputs.
|
|
233
|
+
- **Architecture Health Scorecard**: Live breakdown of Vue `<script setup>` ratio, TypeScript ratio, Pinia stores, and .NET Clean Architecture violations.
|
|
234
|
+
- **Design Token Palette**: Interactive color swatches with one-click hex copying and rule usage counts mined across the repository.
|
|
235
|
+
- **Hotspots Backlog**: Filterable table of god components, deprecated deep selectors, mixins, and legacy code.
|
|
236
|
+
- **Zero Dependencies**: Powered by native `node:http`, starts in under 20ms.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
218
240
|
## Framework command suites
|
|
219
241
|
|
|
220
242
|
During `plankit init`, PlanKit prompts you to choose optional framework-specific command suites (or pass `--framework <name>` / `--frameworks <names>`):
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from './commandBundles.js';
|
|
26
26
|
import { runAnalysis, formatReport } from './analyzer/engine.js';
|
|
27
27
|
import { loadDashboardData, renderStaticDashboard, runInteractiveDashboard } from './dashboard/dashboard.js';
|
|
28
|
+
import { startServer } from './server/server.js';
|
|
28
29
|
|
|
29
30
|
export async function runCli(args, context = {}) {
|
|
30
31
|
const io = createIo(context);
|
|
@@ -61,6 +62,10 @@ export async function runCli(args, context = {}) {
|
|
|
61
62
|
case 'dashboard':
|
|
62
63
|
await showDashboard(parsed, io);
|
|
63
64
|
break;
|
|
65
|
+
case 'serve':
|
|
66
|
+
case 'web':
|
|
67
|
+
await startWebServer(parsed, io);
|
|
68
|
+
break;
|
|
64
69
|
case 'archive':
|
|
65
70
|
archiveFeature(parsed, io);
|
|
66
71
|
break;
|
|
@@ -419,6 +424,56 @@ async function showDashboard(parsed, io) {
|
|
|
419
424
|
});
|
|
420
425
|
}
|
|
421
426
|
|
|
427
|
+
async function startWebServer(parsed, io) {
|
|
428
|
+
const { config } = loadConfig(io.cwd);
|
|
429
|
+
let port = null;
|
|
430
|
+
|
|
431
|
+
const rawPort = parsed.flags.port || parsed.flags.p;
|
|
432
|
+
if (rawPort !== undefined) {
|
|
433
|
+
const parsedNum = Number.parseInt(rawPort, 10);
|
|
434
|
+
if (!Number.isInteger(parsedNum) || parsedNum < 0 || parsedNum > 65535) {
|
|
435
|
+
throw new Error(`Invalid port "${rawPort}". Please provide a valid port between 1 and 65535.`);
|
|
436
|
+
}
|
|
437
|
+
port = parsedNum;
|
|
438
|
+
} else if (io.isTTY && io.promptChoice) {
|
|
439
|
+
const answer = await io.promptChoice('Enter port for PlanKit Web Dashboard (default: 4200): ', ['4200']);
|
|
440
|
+
if (answer && answer.trim()) {
|
|
441
|
+
const parsedNum = Number.parseInt(answer.trim(), 10);
|
|
442
|
+
if (!Number.isInteger(parsedNum) || parsedNum < 0 || parsedNum > 65535) {
|
|
443
|
+
throw new Error(`Invalid port "${answer}". Please provide a valid port between 1 and 65535.`);
|
|
444
|
+
}
|
|
445
|
+
port = parsedNum;
|
|
446
|
+
} else {
|
|
447
|
+
port = 4200;
|
|
448
|
+
}
|
|
449
|
+
} else {
|
|
450
|
+
port = 4200;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
io.stdout(`Starting PlanKit Web Dashboard on port ${port}...`);
|
|
454
|
+
|
|
455
|
+
const { server, port: actualPort } = await startServer({
|
|
456
|
+
cwd: io.cwd,
|
|
457
|
+
config,
|
|
458
|
+
port,
|
|
459
|
+
io
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const url = `http://localhost:${actualPort}`;
|
|
463
|
+
io.stdout('');
|
|
464
|
+
io.stdout('======================================================');
|
|
465
|
+
io.stdout(` PlanKit Web Dashboard running at: ${url}`);
|
|
466
|
+
io.stdout('======================================================');
|
|
467
|
+
io.stdout('Press Ctrl+C to stop.');
|
|
468
|
+
|
|
469
|
+
// Keep process alive if interactive/standalone
|
|
470
|
+
if (!parsed.flags['no-keep-alive'] && parsed.flags['keep-alive'] !== false) {
|
|
471
|
+
await new Promise(() => {});
|
|
472
|
+
} else {
|
|
473
|
+
server.close();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
422
477
|
function moveFeatureToArchive(cwd, config, featureName, dryRun = false) {
|
|
423
478
|
const sourceDir = requireFeatureDir(cwd, config, featureName);
|
|
424
479
|
const targetDir = safeJoin(cwd, config.artifactsDir, 'archived', featureName);
|
|
@@ -790,6 +845,7 @@ Commands:
|
|
|
790
845
|
status [--json] Show active and archived features
|
|
791
846
|
scan [dir] Run static analysis and generate artifacts/.plankit-index.json
|
|
792
847
|
ui Launch interactive feature progress dashboard
|
|
848
|
+
serve [--port <n>] Launch visual local web dashboard companion (alias: web)
|
|
793
849
|
archive <feature> Move a feature to archived without running verification
|
|
794
850
|
version Show the installed PlankKit CLI version
|
|
795
851
|
help Show this help message
|
|
@@ -802,6 +858,7 @@ Options:
|
|
|
802
858
|
--framework <name> Select framework command suite during init
|
|
803
859
|
(angular, vue, dotnet, none, all; default: none)
|
|
804
860
|
--frameworks <list> Comma-separated list of framework suites during init
|
|
861
|
+
--port <number> Specify port for web dashboard (default: 4200)
|
|
805
862
|
--threshold <score> Fail scan if health score is below threshold
|
|
806
863
|
--json Output scan report or status as JSON
|
|
807
864
|
--no-tui Render static summary table instead of interactive dashboard
|
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedded responsive HTML/CSS/JS client for PlanKit Web Dashboard.
|
|
3
|
+
* Single-file, zero-dependency, dark-themed modern dashboard.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function getClientHtml() {
|
|
7
|
+
return `<!DOCTYPE html>
|
|
8
|
+
<html lang="en">
|
|
9
|
+
<head>
|
|
10
|
+
<meta charset="UTF-8" />
|
|
11
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
12
|
+
<title>PlanKit Dashboard</title>
|
|
13
|
+
<style>
|
|
14
|
+
:root {
|
|
15
|
+
--bg: #090d16;
|
|
16
|
+
--card-bg: #111827;
|
|
17
|
+
--border: #1f2937;
|
|
18
|
+
--border-focus: #3b82f6;
|
|
19
|
+
--text: #f3f4f6;
|
|
20
|
+
--text-muted: #9ca3af;
|
|
21
|
+
--primary: #3b82f6;
|
|
22
|
+
--primary-hover: #2563eb;
|
|
23
|
+
--success: #10b981;
|
|
24
|
+
--warning: #f59e0b;
|
|
25
|
+
--danger: #ef4444;
|
|
26
|
+
--badge-bg: #1e293b;
|
|
27
|
+
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
28
|
+
}
|
|
29
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
30
|
+
body {
|
|
31
|
+
background: var(--bg);
|
|
32
|
+
color: var(--text);
|
|
33
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
34
|
+
line-height: 1.5;
|
|
35
|
+
padding-bottom: 60px;
|
|
36
|
+
}
|
|
37
|
+
header {
|
|
38
|
+
background: #0f172a;
|
|
39
|
+
border-bottom: 1px solid var(--border);
|
|
40
|
+
padding: 16px 32px;
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
justify-content: space-between;
|
|
44
|
+
position: sticky;
|
|
45
|
+
top: 0;
|
|
46
|
+
z-index: 100;
|
|
47
|
+
}
|
|
48
|
+
.brand {
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: 12px;
|
|
52
|
+
font-size: 1.25rem;
|
|
53
|
+
font-weight: 700;
|
|
54
|
+
letter-spacing: -0.025em;
|
|
55
|
+
}
|
|
56
|
+
.brand-badge {
|
|
57
|
+
background: var(--primary);
|
|
58
|
+
color: #fff;
|
|
59
|
+
font-size: 0.75rem;
|
|
60
|
+
padding: 2px 8px;
|
|
61
|
+
border-radius: 9999px;
|
|
62
|
+
font-family: var(--font-mono);
|
|
63
|
+
}
|
|
64
|
+
.header-actions {
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: 12px;
|
|
68
|
+
}
|
|
69
|
+
button {
|
|
70
|
+
background: var(--primary);
|
|
71
|
+
color: #fff;
|
|
72
|
+
border: none;
|
|
73
|
+
padding: 8px 16px;
|
|
74
|
+
border-radius: 6px;
|
|
75
|
+
font-weight: 600;
|
|
76
|
+
font-size: 0.875rem;
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
transition: background 0.15s ease;
|
|
79
|
+
display: inline-flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
gap: 6px;
|
|
82
|
+
}
|
|
83
|
+
button:hover { background: var(--primary-hover); }
|
|
84
|
+
button.secondary {
|
|
85
|
+
background: var(--card-bg);
|
|
86
|
+
border: 1px solid var(--border);
|
|
87
|
+
color: var(--text);
|
|
88
|
+
}
|
|
89
|
+
button.secondary:hover { background: #1e293b; }
|
|
90
|
+
|
|
91
|
+
.nav-tabs {
|
|
92
|
+
display: flex;
|
|
93
|
+
gap: 8px;
|
|
94
|
+
background: #0f172a;
|
|
95
|
+
padding: 8px 32px 0 32px;
|
|
96
|
+
border-bottom: 1px solid var(--border);
|
|
97
|
+
}
|
|
98
|
+
.tab {
|
|
99
|
+
padding: 10px 18px;
|
|
100
|
+
color: var(--text-muted);
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
border-bottom: 2px solid transparent;
|
|
103
|
+
font-size: 0.9rem;
|
|
104
|
+
font-weight: 500;
|
|
105
|
+
}
|
|
106
|
+
.tab.active {
|
|
107
|
+
color: var(--primary);
|
|
108
|
+
border-bottom-color: var(--primary);
|
|
109
|
+
font-weight: 600;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
main {
|
|
113
|
+
max-width: 1300px;
|
|
114
|
+
margin: 32px auto;
|
|
115
|
+
padding: 0 32px;
|
|
116
|
+
}
|
|
117
|
+
.tab-content { display: none; }
|
|
118
|
+
.tab-content.active { display: block; }
|
|
119
|
+
|
|
120
|
+
.stats-grid {
|
|
121
|
+
display: grid;
|
|
122
|
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
123
|
+
gap: 20px;
|
|
124
|
+
margin-bottom: 32px;
|
|
125
|
+
}
|
|
126
|
+
.stat-card {
|
|
127
|
+
background: var(--card-bg);
|
|
128
|
+
border: 1px solid var(--border);
|
|
129
|
+
border-radius: 10px;
|
|
130
|
+
padding: 20px;
|
|
131
|
+
}
|
|
132
|
+
.stat-title {
|
|
133
|
+
color: var(--text-muted);
|
|
134
|
+
font-size: 0.85rem;
|
|
135
|
+
font-weight: 500;
|
|
136
|
+
margin-bottom: 8px;
|
|
137
|
+
}
|
|
138
|
+
.stat-value {
|
|
139
|
+
font-size: 2rem;
|
|
140
|
+
font-weight: 700;
|
|
141
|
+
letter-spacing: -0.025em;
|
|
142
|
+
display: flex;
|
|
143
|
+
align-items: baseline;
|
|
144
|
+
gap: 8px;
|
|
145
|
+
}
|
|
146
|
+
.stat-sub {
|
|
147
|
+
font-size: 0.85rem;
|
|
148
|
+
color: var(--text-muted);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.card {
|
|
152
|
+
background: var(--card-bg);
|
|
153
|
+
border: 1px solid var(--border);
|
|
154
|
+
border-radius: 10px;
|
|
155
|
+
padding: 24px;
|
|
156
|
+
margin-bottom: 24px;
|
|
157
|
+
}
|
|
158
|
+
.card-header {
|
|
159
|
+
display: flex;
|
|
160
|
+
justify-content: space-between;
|
|
161
|
+
align-items: center;
|
|
162
|
+
margin-bottom: 20px;
|
|
163
|
+
}
|
|
164
|
+
.card-title {
|
|
165
|
+
font-size: 1.15rem;
|
|
166
|
+
font-weight: 600;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.feature-list {
|
|
170
|
+
display: flex;
|
|
171
|
+
flex-direction: column;
|
|
172
|
+
gap: 16px;
|
|
173
|
+
}
|
|
174
|
+
.feature-item {
|
|
175
|
+
background: #0f172a;
|
|
176
|
+
border: 1px solid var(--border);
|
|
177
|
+
border-radius: 8px;
|
|
178
|
+
padding: 16px 20px;
|
|
179
|
+
display: flex;
|
|
180
|
+
flex-direction: column;
|
|
181
|
+
gap: 12px;
|
|
182
|
+
}
|
|
183
|
+
.feature-item-header {
|
|
184
|
+
display: flex;
|
|
185
|
+
justify-content: space-between;
|
|
186
|
+
align-items: center;
|
|
187
|
+
}
|
|
188
|
+
.feature-name {
|
|
189
|
+
font-size: 1.1rem;
|
|
190
|
+
font-weight: 600;
|
|
191
|
+
font-family: var(--font-mono);
|
|
192
|
+
color: var(--text);
|
|
193
|
+
}
|
|
194
|
+
.progress-bar-container {
|
|
195
|
+
background: #1e293b;
|
|
196
|
+
height: 8px;
|
|
197
|
+
border-radius: 9999px;
|
|
198
|
+
overflow: hidden;
|
|
199
|
+
margin: 6px 0;
|
|
200
|
+
}
|
|
201
|
+
.progress-bar-fill {
|
|
202
|
+
background: var(--primary);
|
|
203
|
+
height: 100%;
|
|
204
|
+
transition: width 0.3s ease;
|
|
205
|
+
}
|
|
206
|
+
.phases-grid {
|
|
207
|
+
display: grid;
|
|
208
|
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
209
|
+
gap: 12px;
|
|
210
|
+
margin-top: 8px;
|
|
211
|
+
}
|
|
212
|
+
.phase-pill {
|
|
213
|
+
background: var(--badge-bg);
|
|
214
|
+
border: 1px solid var(--border);
|
|
215
|
+
padding: 8px 12px;
|
|
216
|
+
border-radius: 6px;
|
|
217
|
+
font-size: 0.825rem;
|
|
218
|
+
display: flex;
|
|
219
|
+
justify-content: space-between;
|
|
220
|
+
align-items: center;
|
|
221
|
+
cursor: pointer;
|
|
222
|
+
}
|
|
223
|
+
.phase-pill:hover { border-color: var(--primary); }
|
|
224
|
+
.badge {
|
|
225
|
+
font-size: 0.7rem;
|
|
226
|
+
font-weight: 600;
|
|
227
|
+
padding: 2px 6px;
|
|
228
|
+
border-radius: 4px;
|
|
229
|
+
font-family: var(--font-mono);
|
|
230
|
+
}
|
|
231
|
+
.badge-done { background: rgba(16, 185, 129, 0.2); color: var(--success); }
|
|
232
|
+
.badge-pending { background: rgba(245, 158, 11, 0.2); color: var(--warning); }
|
|
233
|
+
|
|
234
|
+
.tokens-grid {
|
|
235
|
+
display: grid;
|
|
236
|
+
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
237
|
+
gap: 16px;
|
|
238
|
+
}
|
|
239
|
+
.token-card {
|
|
240
|
+
background: #0f172a;
|
|
241
|
+
border: 1px solid var(--border);
|
|
242
|
+
border-radius: 8px;
|
|
243
|
+
overflow: hidden;
|
|
244
|
+
cursor: pointer;
|
|
245
|
+
transition: transform 0.15s ease, border-color 0.15s ease;
|
|
246
|
+
}
|
|
247
|
+
.token-card:hover {
|
|
248
|
+
transform: translateY(-2px);
|
|
249
|
+
border-color: var(--primary);
|
|
250
|
+
}
|
|
251
|
+
.token-swatch {
|
|
252
|
+
height: 70px;
|
|
253
|
+
width: 100%;
|
|
254
|
+
border-bottom: 1px solid var(--border);
|
|
255
|
+
}
|
|
256
|
+
.token-info {
|
|
257
|
+
padding: 10px 12px;
|
|
258
|
+
}
|
|
259
|
+
.token-hex {
|
|
260
|
+
font-family: var(--font-mono);
|
|
261
|
+
font-size: 0.85rem;
|
|
262
|
+
font-weight: 600;
|
|
263
|
+
}
|
|
264
|
+
.token-count {
|
|
265
|
+
font-size: 0.75rem;
|
|
266
|
+
color: var(--text-muted);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
table {
|
|
270
|
+
width: 100%;
|
|
271
|
+
border-collapse: collapse;
|
|
272
|
+
font-size: 0.875rem;
|
|
273
|
+
}
|
|
274
|
+
th, td {
|
|
275
|
+
text-align: left;
|
|
276
|
+
padding: 10px 14px;
|
|
277
|
+
border-bottom: 1px solid var(--border);
|
|
278
|
+
}
|
|
279
|
+
th {
|
|
280
|
+
color: var(--text-muted);
|
|
281
|
+
font-weight: 600;
|
|
282
|
+
font-size: 0.775rem;
|
|
283
|
+
text-transform: uppercase;
|
|
284
|
+
letter-spacing: 0.05em;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* Modal */
|
|
288
|
+
.modal-overlay {
|
|
289
|
+
position: fixed;
|
|
290
|
+
inset: 0;
|
|
291
|
+
background: rgba(0, 0, 0, 0.75);
|
|
292
|
+
display: none;
|
|
293
|
+
align-items: center;
|
|
294
|
+
justify-content: center;
|
|
295
|
+
z-index: 200;
|
|
296
|
+
padding: 24px;
|
|
297
|
+
}
|
|
298
|
+
.modal-overlay.open { display: flex; }
|
|
299
|
+
.modal {
|
|
300
|
+
background: var(--card-bg);
|
|
301
|
+
border: 1px solid var(--border);
|
|
302
|
+
border-radius: 12px;
|
|
303
|
+
width: 100%;
|
|
304
|
+
max-width: 800px;
|
|
305
|
+
max-height: 85vh;
|
|
306
|
+
display: flex;
|
|
307
|
+
flex-direction: column;
|
|
308
|
+
overflow: hidden;
|
|
309
|
+
}
|
|
310
|
+
.modal-header {
|
|
311
|
+
padding: 16px 24px;
|
|
312
|
+
border-bottom: 1px solid var(--border);
|
|
313
|
+
display: flex;
|
|
314
|
+
justify-content: space-between;
|
|
315
|
+
align-items: center;
|
|
316
|
+
}
|
|
317
|
+
.modal-body {
|
|
318
|
+
padding: 24px;
|
|
319
|
+
overflow-y: auto;
|
|
320
|
+
font-family: var(--font-mono);
|
|
321
|
+
font-size: 0.85rem;
|
|
322
|
+
white-space: pre-wrap;
|
|
323
|
+
background: #090d16;
|
|
324
|
+
color: #e5e7eb;
|
|
325
|
+
}
|
|
326
|
+
.close-btn {
|
|
327
|
+
background: transparent;
|
|
328
|
+
color: var(--text-muted);
|
|
329
|
+
font-size: 1.25rem;
|
|
330
|
+
padding: 4px;
|
|
331
|
+
}
|
|
332
|
+
.close-btn:hover { color: #fff; background: transparent; }
|
|
333
|
+
|
|
334
|
+
.toast {
|
|
335
|
+
position: fixed;
|
|
336
|
+
bottom: 24px;
|
|
337
|
+
right: 24px;
|
|
338
|
+
background: #1e293b;
|
|
339
|
+
border: 1px solid var(--primary);
|
|
340
|
+
color: #fff;
|
|
341
|
+
padding: 12px 20px;
|
|
342
|
+
border-radius: 8px;
|
|
343
|
+
font-size: 0.875rem;
|
|
344
|
+
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
|
345
|
+
display: none;
|
|
346
|
+
z-index: 300;
|
|
347
|
+
}
|
|
348
|
+
</style>
|
|
349
|
+
</head>
|
|
350
|
+
<body>
|
|
351
|
+
|
|
352
|
+
<header>
|
|
353
|
+
<div class="brand">
|
|
354
|
+
<span>PlanKit</span>
|
|
355
|
+
<span class="brand-badge">Dashboard</span>
|
|
356
|
+
</div>
|
|
357
|
+
<div class="header-actions">
|
|
358
|
+
<button class="secondary" id="btnScan">⚡ Run Health Scan</button>
|
|
359
|
+
</div>
|
|
360
|
+
</header>
|
|
361
|
+
|
|
362
|
+
<div class="nav-tabs">
|
|
363
|
+
<div class="tab active" data-target="tabFeatures">Feature Matrix</div>
|
|
364
|
+
<div class="tab" data-target="tabHealth">Architecture Health</div>
|
|
365
|
+
<div class="tab" data-target="tabTokens">Design Tokens</div>
|
|
366
|
+
<div class="tab" data-target="tabHotspots">Hotspots Backlog</div>
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
<main>
|
|
370
|
+
<div class="stats-grid">
|
|
371
|
+
<div class="stat-card">
|
|
372
|
+
<div class="stat-title">Active Features</div>
|
|
373
|
+
<div class="stat-value" id="statActive">0</div>
|
|
374
|
+
<div class="stat-sub" id="statArchived">0 archived</div>
|
|
375
|
+
</div>
|
|
376
|
+
<div class="stat-card">
|
|
377
|
+
<div class="stat-title">Repository Health</div>
|
|
378
|
+
<div class="stat-value" id="statScore">-- <span class="stat-sub" id="statGrade"></span></div>
|
|
379
|
+
<div class="stat-sub" id="statFiles">0 files scanned</div>
|
|
380
|
+
</div>
|
|
381
|
+
<div class="stat-card">
|
|
382
|
+
<div class="stat-title">Vue Script Setup Ratio</div>
|
|
383
|
+
<div class="stat-value" id="statVueRatio">--%</div>
|
|
384
|
+
<div class="stat-sub" id="statVueTs">TypeScript: --%</div>
|
|
385
|
+
</div>
|
|
386
|
+
<div class="stat-card">
|
|
387
|
+
<div class="stat-title">Mined Design Tokens</div>
|
|
388
|
+
<div class="stat-value" id="statTokens">0</div>
|
|
389
|
+
<div class="stat-sub">Color palette candidates</div>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
<!-- TAB 1: FEATURES -->
|
|
394
|
+
<section class="tab-content active" id="tabFeatures">
|
|
395
|
+
<div class="card">
|
|
396
|
+
<div class="card-header">
|
|
397
|
+
<div class="card-title">Active Feature Workspaces (artifacts/current/)</div>
|
|
398
|
+
</div>
|
|
399
|
+
<div class="feature-list" id="featureList">
|
|
400
|
+
<div style="color: var(--text-muted);">Loading features...</div>
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
</section>
|
|
404
|
+
|
|
405
|
+
<!-- TAB 2: HEALTH -->
|
|
406
|
+
<section class="tab-content" id="tabHealth">
|
|
407
|
+
<div class="card">
|
|
408
|
+
<div class="card-header">
|
|
409
|
+
<div class="card-title">Architecture Modernization Scorecard</div>
|
|
410
|
+
</div>
|
|
411
|
+
<div id="healthBreakdown">Loading health metrics...</div>
|
|
412
|
+
</div>
|
|
413
|
+
</section>
|
|
414
|
+
|
|
415
|
+
<!-- TAB 3: TOKENS -->
|
|
416
|
+
<section class="tab-content" id="tabTokens">
|
|
417
|
+
<div class="card">
|
|
418
|
+
<div class="card-header">
|
|
419
|
+
<div class="card-title">Discovered Design Tokens (Colors & Spacing)</div>
|
|
420
|
+
<span style="font-size: 0.8rem; color: var(--text-muted);">Click swatch to copy hex</span>
|
|
421
|
+
</div>
|
|
422
|
+
<div class="tokens-grid" id="tokensGrid">
|
|
423
|
+
<div style="color: var(--text-muted);">Scanning for tokens...</div>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
</section>
|
|
427
|
+
|
|
428
|
+
<!-- TAB 4: HOTSPOTS -->
|
|
429
|
+
<section class="tab-content" id="tabHotspots">
|
|
430
|
+
<div class="card">
|
|
431
|
+
<div class="card-header">
|
|
432
|
+
<div class="card-title">Modernization Hotspots & Anti-Patterns</div>
|
|
433
|
+
</div>
|
|
434
|
+
<table>
|
|
435
|
+
<thead>
|
|
436
|
+
<tr>
|
|
437
|
+
<th>File</th>
|
|
438
|
+
<th>Type</th>
|
|
439
|
+
<th>Issue Description</th>
|
|
440
|
+
</tr>
|
|
441
|
+
</thead>
|
|
442
|
+
<tbody id="hotspotsTable">
|
|
443
|
+
<tr><td colspan="3" style="color: var(--text-muted);">No hotspots detected.</td></tr>
|
|
444
|
+
</tbody>
|
|
445
|
+
</table>
|
|
446
|
+
</div>
|
|
447
|
+
</section>
|
|
448
|
+
</main>
|
|
449
|
+
|
|
450
|
+
<!-- Modal for viewing Spec or Output -->
|
|
451
|
+
<div class="modal-overlay" id="fileModal">
|
|
452
|
+
<div class="modal">
|
|
453
|
+
<div class="modal-header">
|
|
454
|
+
<h3 id="modalTitle" style="font-size: 1rem; font-family: var(--font-mono);">Document</h3>
|
|
455
|
+
<button class="close-btn" id="modalClose">×</button>
|
|
456
|
+
</div>
|
|
457
|
+
<div class="modal-body" id="modalContent"></div>
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
<div class="toast" id="toast">Copied to clipboard!</div>
|
|
462
|
+
|
|
463
|
+
<script>
|
|
464
|
+
let dashboardData = null;
|
|
465
|
+
|
|
466
|
+
async function loadData() {
|
|
467
|
+
try {
|
|
468
|
+
const res = await fetch('/api/data');
|
|
469
|
+
dashboardData = await res.json();
|
|
470
|
+
renderAll();
|
|
471
|
+
} catch (err) {
|
|
472
|
+
console.error('Failed to load dashboard data:', err);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function renderAll() {
|
|
477
|
+
if (!dashboardData) return;
|
|
478
|
+
|
|
479
|
+
const { activeFeatures, archivedCount, healthReport } = dashboardData;
|
|
480
|
+
|
|
481
|
+
// Stats
|
|
482
|
+
document.getElementById('statActive').innerText = activeFeatures.length;
|
|
483
|
+
document.getElementById('statArchived').innerText = \`\${archivedCount} archived\`;
|
|
484
|
+
|
|
485
|
+
if (healthReport) {
|
|
486
|
+
document.getElementById('statScore').innerHTML = \`\${healthReport.score.total} <span class="stat-sub" style="font-size: 1rem;">/ 100 (\${healthReport.score.grade})</span>\`;
|
|
487
|
+
document.getElementById('statFiles').innerText = \`\${healthReport.totalFilesScanned} files scanned\`;
|
|
488
|
+
|
|
489
|
+
if (healthReport.vue) {
|
|
490
|
+
document.getElementById('statVueRatio').innerText = \`\${healthReport.vue.scriptSetupPercent}%\`;
|
|
491
|
+
document.getElementById('statVueTs').innerText = \`TypeScript: \${healthReport.vue.tsPercent}%\`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (healthReport.designTokens) {
|
|
495
|
+
document.getElementById('statTokens').innerText = healthReport.designTokens.frequentColors.length;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Feature list
|
|
500
|
+
renderFeatures(activeFeatures);
|
|
501
|
+
// Health breakdown
|
|
502
|
+
renderHealth(healthReport);
|
|
503
|
+
// Tokens
|
|
504
|
+
renderTokens(healthReport ? healthReport.designTokens : null);
|
|
505
|
+
// Hotspots
|
|
506
|
+
renderHotspots(healthReport ? healthReport.hotspots : []);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function renderFeatures(features) {
|
|
510
|
+
const container = document.getElementById('featureList');
|
|
511
|
+
if (!features || features.length === 0) {
|
|
512
|
+
container.innerHTML = '<div style="color: var(--text-muted); padding: 20px 0;">No active features in artifacts/current/. Plan a feature with <code>plankit plan <feature></code>.</div>';
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
container.innerHTML = features.map(feat => {
|
|
517
|
+
const phasesHtml = feat.phases.map(p => {
|
|
518
|
+
const badgeClass = p.hasOutput ? 'badge-done' : 'badge-pending';
|
|
519
|
+
const label = p.hasOutput ? 'DONE' : p.status;
|
|
520
|
+
return \`
|
|
521
|
+
<div class="phase-pill" onclick="viewDocument('\${feat.name}', \${p.number}, '\${p.hasOutput ? 'output' : 'spec'}')">
|
|
522
|
+
<span>Phase \${p.number}</span>
|
|
523
|
+
<span class="badge \${badgeClass}">\${label}</span>
|
|
524
|
+
</div>
|
|
525
|
+
\`;
|
|
526
|
+
}).join('');
|
|
527
|
+
|
|
528
|
+
return \`
|
|
529
|
+
<div class="feature-item">
|
|
530
|
+
<div class="feature-item-header">
|
|
531
|
+
<span class="feature-name">\${feat.name}</span>
|
|
532
|
+
<span style="font-size: 0.85rem; color: var(--text-muted);">\${feat.completedCount} / \${feat.phaseCount} completed (\${feat.progressPercent}%)</span>
|
|
533
|
+
</div>
|
|
534
|
+
<div class="progress-bar-container">
|
|
535
|
+
<div class="progress-bar-fill" style="width: \${feat.progressPercent}%;"></div>
|
|
536
|
+
</div>
|
|
537
|
+
<div class="phases-grid">
|
|
538
|
+
\${phasesHtml}
|
|
539
|
+
</div>
|
|
540
|
+
</div>
|
|
541
|
+
\`;
|
|
542
|
+
}).join('');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function renderHealth(health) {
|
|
546
|
+
const container = document.getElementById('healthBreakdown');
|
|
547
|
+
if (!health) {
|
|
548
|
+
container.innerHTML = '<div style="color: var(--text-muted);">No health scan available. Click "Run Health Scan" above.</div>';
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
let html = \`
|
|
553
|
+
<div style="display: flex; gap: 32px; align-items: center; margin-bottom: 24px;">
|
|
554
|
+
<div style="font-size: 3.5rem; font-weight: 800; color: \${health.score.total >= 80 ? 'var(--success)' : 'var(--warning)'}">
|
|
555
|
+
\${health.score.total}<span style="font-size: 1.5rem; color: var(--text-muted);">/100</span>
|
|
556
|
+
</div>
|
|
557
|
+
<div>
|
|
558
|
+
<div style="font-size: 1.1rem; font-weight: 600;">Overall Grade: \${health.score.grade}</div>
|
|
559
|
+
<div style="color: var(--text-muted); font-size: 0.875rem;">Last scanned: \${new Date(health.timestamp).toLocaleTimeString()}</div>
|
|
560
|
+
</div>
|
|
561
|
+
</div>
|
|
562
|
+
\`;
|
|
563
|
+
|
|
564
|
+
if (health.vue) {
|
|
565
|
+
html += \`
|
|
566
|
+
<h4 style="margin-bottom: 12px; font-size: 0.95rem;">Vue SFC Metrics</h4>
|
|
567
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px;">
|
|
568
|
+
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
|
569
|
+
<div style="font-size: 0.75rem; color: var(--text-muted);">Preferred (Modern)</div>
|
|
570
|
+
<div style="font-size: 1.25rem; font-weight: 700; color: var(--success);">\${health.vue.maturity.preferred}</div>
|
|
571
|
+
</div>
|
|
572
|
+
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
|
573
|
+
<div style="font-size: 0.75rem; color: var(--text-muted);">Transitional</div>
|
|
574
|
+
<div style="font-size: 1.25rem; font-weight: 700; color: var(--warning);">\${health.vue.maturity.transitional}</div>
|
|
575
|
+
</div>
|
|
576
|
+
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
|
577
|
+
<div style="font-size: 0.75rem; color: var(--text-muted);">Legacy</div>
|
|
578
|
+
<div style="font-size: 1.25rem; font-weight: 700; color: var(--danger);">\${health.vue.maturity.legacy}</div>
|
|
579
|
+
</div>
|
|
580
|
+
</div>
|
|
581
|
+
\`;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
container.innerHTML = html;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function renderTokens(tokens) {
|
|
588
|
+
const container = document.getElementById('tokensGrid');
|
|
589
|
+
if (!tokens || !tokens.frequentColors || tokens.frequentColors.length === 0) {
|
|
590
|
+
container.innerHTML = '<div style="color: var(--text-muted);">No repeated design token candidates discovered yet.</div>';
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
container.innerHTML = tokens.frequentColors.map(t => \`
|
|
595
|
+
<div class="token-card" onclick="copyToken('\${t.color}')">
|
|
596
|
+
<div class="token-swatch" style="background-color: \${t.color};"></div>
|
|
597
|
+
<div class="token-info">
|
|
598
|
+
<div class="token-hex">\${t.color}</div>
|
|
599
|
+
<div class="token-count">used in \${t.count} rules</div>
|
|
600
|
+
</div>
|
|
601
|
+
</div>
|
|
602
|
+
\`).join('');
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function renderHotspots(hotspots) {
|
|
606
|
+
const tbody = document.getElementById('hotspotsTable');
|
|
607
|
+
if (!hotspots || hotspots.length === 0) {
|
|
608
|
+
tbody.innerHTML = '<tr><td colspan="3" style="color: var(--success); padding: 16px;">✓ Clean! Zero critical legacy hotspots or architectural violations detected.</td></tr>';
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
tbody.innerHTML = hotspots.map(h => \`
|
|
613
|
+
<tr>
|
|
614
|
+
<td style="font-family: var(--font-mono); font-weight: 600;">\${h.file}</td>
|
|
615
|
+
<td><span class="badge badge-pending">\${h.type}</span></td>
|
|
616
|
+
<td>\${h.message}</td>
|
|
617
|
+
</tr>
|
|
618
|
+
\`).join('');
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function viewDocument(feature, phase, type) {
|
|
622
|
+
try {
|
|
623
|
+
const url = \`/api/\${type}?feature=\${encodeURIComponent(feature)}&phase=\${phase}\`;
|
|
624
|
+
const res = await fetch(url);
|
|
625
|
+
const data = await res.json();
|
|
626
|
+
document.getElementById('modalTitle').innerText = \`\${feature} - Phase \${phase} (\${type.toUpperCase()})\`;
|
|
627
|
+
document.getElementById('modalContent').innerText = data.content || '(empty document)';
|
|
628
|
+
document.getElementById('fileModal').classList.add('open');
|
|
629
|
+
} catch (err) {
|
|
630
|
+
console.error('Failed to view document:', err);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function copyToken(text) {
|
|
635
|
+
navigator.clipboard.writeText(text);
|
|
636
|
+
const toast = document.getElementById('toast');
|
|
637
|
+
toast.innerText = \`Copied \${text} to clipboard!\`;
|
|
638
|
+
toast.style.display = 'block';
|
|
639
|
+
setTimeout(() => { toast.style.display = 'none'; }, 2000);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Event Listeners
|
|
643
|
+
document.querySelectorAll('.tab').forEach(tab => {
|
|
644
|
+
tab.addEventListener('click', () => {
|
|
645
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
646
|
+
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
|
|
647
|
+
tab.classList.add('active');
|
|
648
|
+
document.getElementById(tab.dataset.target).classList.add('active');
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
document.getElementById('modalClose').addEventListener('click', () => {
|
|
653
|
+
document.getElementById('fileModal').classList.remove('open');
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
document.getElementById('btnScan').addEventListener('click', async () => {
|
|
657
|
+
const btn = document.getElementById('btnScan');
|
|
658
|
+
btn.innerText = 'Scanning...';
|
|
659
|
+
btn.disabled = true;
|
|
660
|
+
try {
|
|
661
|
+
await fetch('/api/scan', { method: 'POST' });
|
|
662
|
+
await loadData();
|
|
663
|
+
} finally {
|
|
664
|
+
btn.innerText = '⚡ Run Health Scan';
|
|
665
|
+
btn.disabled = false;
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
loadData();
|
|
670
|
+
setInterval(loadData, 5000); // auto-refresh every 5s
|
|
671
|
+
</script>
|
|
672
|
+
</body>
|
|
673
|
+
</html>`;
|
|
674
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { URL } from 'node:url';
|
|
5
|
+
import { loadDashboardData } from '../dashboard/dashboard.js';
|
|
6
|
+
import { runAnalysis } from '../analyzer/engine.js';
|
|
7
|
+
import { getClientHtml } from './clientHtml.js';
|
|
8
|
+
import { safeJoin } from '../config.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Native, zero-dependency HTTP server for PlanKit Web Dashboard.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export function createWebServer({ cwd, config, io }) {
|
|
15
|
+
const server = http.createServer(async (req, res) => {
|
|
16
|
+
try {
|
|
17
|
+
const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
18
|
+
const pathname = parsedUrl.pathname;
|
|
19
|
+
|
|
20
|
+
// CORS headers for local development
|
|
21
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
22
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
23
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
24
|
+
|
|
25
|
+
if (req.method === 'OPTIONS') {
|
|
26
|
+
res.writeHead(204);
|
|
27
|
+
res.end();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Root HTML page
|
|
32
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
33
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
34
|
+
res.end(getClientHtml());
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// API: Combined data (active features, archived count, and latest health scan)
|
|
39
|
+
if (pathname === '/api/data' && req.method === 'GET') {
|
|
40
|
+
const dashboardData = loadDashboardData(cwd, config);
|
|
41
|
+
|
|
42
|
+
let healthReport = null;
|
|
43
|
+
const indexPath = path.join(cwd, config.artifactsDir || 'artifacts', '.plankit-index.json');
|
|
44
|
+
if (fs.existsSync(indexPath)) {
|
|
45
|
+
try {
|
|
46
|
+
healthReport = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
|
47
|
+
} catch {
|
|
48
|
+
healthReport = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// If no index exists yet, run a fast scan
|
|
53
|
+
if (!healthReport) {
|
|
54
|
+
healthReport = await runAnalysis(cwd, { artifactsDir: config.artifactsDir });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
58
|
+
res.end(JSON.stringify({
|
|
59
|
+
activeFeatures: dashboardData.activeFeatures,
|
|
60
|
+
archivedCount: dashboardData.archivedCount,
|
|
61
|
+
healthReport
|
|
62
|
+
}));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// API: Trigger live re-scan
|
|
67
|
+
if (pathname === '/api/scan' && req.method === 'POST') {
|
|
68
|
+
const report = await runAnalysis(cwd, { artifactsDir: config.artifactsDir });
|
|
69
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
70
|
+
res.end(JSON.stringify(report));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// API: View Phase Spec Markdown
|
|
75
|
+
if (pathname === '/api/spec' && req.method === 'GET') {
|
|
76
|
+
const feature = parsedUrl.searchParams.get('feature');
|
|
77
|
+
const phase = parsedUrl.searchParams.get('phase');
|
|
78
|
+
if (!feature || !phase) {
|
|
79
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
80
|
+
res.end(JSON.stringify({ error: 'Missing feature or phase parameter' }));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const phasesDir = safeJoin(cwd, config.artifactsDir || 'artifacts', 'current', feature, 'phases');
|
|
85
|
+
const prefix = `phase-${phase}-`;
|
|
86
|
+
const file = fs.existsSync(phasesDir)
|
|
87
|
+
? fs.readdirSync(phasesDir).find((n) => n.startsWith(prefix) && n.endsWith('.md'))
|
|
88
|
+
: null;
|
|
89
|
+
|
|
90
|
+
if (!file) {
|
|
91
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
92
|
+
res.end(JSON.stringify({ error: `Spec for Phase ${phase} not found` }));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const content = fs.readFileSync(path.join(phasesDir, file), 'utf8');
|
|
97
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
98
|
+
res.end(JSON.stringify({ file, content }));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// API: View Phase Output Report Markdown
|
|
103
|
+
if (pathname === '/api/output' && req.method === 'GET') {
|
|
104
|
+
const feature = parsedUrl.searchParams.get('feature');
|
|
105
|
+
const phase = parsedUrl.searchParams.get('phase');
|
|
106
|
+
if (!feature || !phase) {
|
|
107
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
108
|
+
res.end(JSON.stringify({ error: 'Missing feature or phase parameter' }));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const outputPath = safeJoin(cwd, config.artifactsDir || 'artifacts', 'current', feature, 'outputs', `phase-${phase}-output.md`);
|
|
113
|
+
if (!fs.existsSync(outputPath)) {
|
|
114
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
115
|
+
res.end(JSON.stringify({ error: `Output report for Phase ${phase} not found` }));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const content = fs.readFileSync(outputPath, 'utf8');
|
|
120
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
121
|
+
res.end(JSON.stringify({ file: path.basename(outputPath), content }));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 404
|
|
126
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
127
|
+
res.end(JSON.stringify({ error: 'Not Found' }));
|
|
128
|
+
} catch (err) {
|
|
129
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
130
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return server;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function startServer({ cwd, config, port = 4200, io }) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const server = createWebServer({ cwd, config, io });
|
|
140
|
+
|
|
141
|
+
server.once('error', (err) => {
|
|
142
|
+
if (err.code === 'EADDRINUSE') {
|
|
143
|
+
reject(new Error(`Port ${port} is already in use. Try passing a different port with --port <number>.`));
|
|
144
|
+
} else {
|
|
145
|
+
reject(err);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
server.listen(port, () => {
|
|
150
|
+
const address = server.address();
|
|
151
|
+
const actualPort = typeof address === 'object' ? address.port : port;
|
|
152
|
+
resolve({ server, port: actualPort });
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|