plankit-cli 1.4.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.
@@ -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
+ }