@grainulation/harvest 1.0.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/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/harvest.js +284 -0
- package/lib/analyzer.js +88 -0
- package/lib/calibration.js +153 -0
- package/lib/dashboard.js +126 -0
- package/lib/decay.js +124 -0
- package/lib/farmer.js +107 -0
- package/lib/patterns.js +199 -0
- package/lib/report.js +125 -0
- package/lib/server.js +494 -0
- package/lib/templates.js +80 -0
- package/lib/velocity.js +177 -0
- package/package.json +51 -0
- package/public/index.html +982 -0
- package/templates/dashboard.html +1230 -0
- package/templates/retrospective.html +315 -0
package/lib/server.js
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* harvest serve -- local HTTP server for the harvest retrospective dashboard
|
|
4
|
+
*
|
|
5
|
+
* Sprint analytics: type distribution, evidence quality, velocity, decay alerts.
|
|
6
|
+
* SSE for live updates. Zero npm dependencies (node:http only).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* harvest serve [--port 9096] [--root /path/to/sprints]
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { join, resolve, extname, dirname, basename } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
|
|
21
|
+
// ── Crash handlers ──
|
|
22
|
+
process.on('uncaughtException', (err) => {
|
|
23
|
+
process.stderr.write(`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
26
|
+
process.on('unhandledRejection', (reason) => {
|
|
27
|
+
process.stderr.write(`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const PUBLIC_DIR = join(__dirname, '..', 'public');
|
|
31
|
+
|
|
32
|
+
// ── CLI args ──────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const args = process.argv.slice(2);
|
|
35
|
+
function arg(name, fallback) {
|
|
36
|
+
const i = args.indexOf(`--${name}`);
|
|
37
|
+
return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PORT = parseInt(arg('port', '9096'), 10);
|
|
41
|
+
const CORS_ORIGIN = arg('cors', null);
|
|
42
|
+
|
|
43
|
+
// Resolve ROOT: walk up from cwd to find a directory with claims.json
|
|
44
|
+
function resolveRoot(initial) {
|
|
45
|
+
if (existsSync(join(initial, 'claims.json'))) return initial;
|
|
46
|
+
let dir = initial;
|
|
47
|
+
for (let i = 0; i < 5; i++) {
|
|
48
|
+
const parent = resolve(dir, '..');
|
|
49
|
+
if (parent === dir) break;
|
|
50
|
+
dir = parent;
|
|
51
|
+
if (existsSync(join(dir, 'claims.json'))) return dir;
|
|
52
|
+
}
|
|
53
|
+
return initial; // fall back to original
|
|
54
|
+
}
|
|
55
|
+
const ROOT = resolveRoot(resolve(arg('root', process.cwd())));
|
|
56
|
+
|
|
57
|
+
// ── Verbose logging ──────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
|
|
60
|
+
function vlog(...a) {
|
|
61
|
+
if (!verbose) return;
|
|
62
|
+
const ts = new Date().toISOString();
|
|
63
|
+
process.stderr.write(`[${ts}] harvest: ${a.join(' ')}\n`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Routes manifest ──────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
const ROUTES = [
|
|
69
|
+
{ method: 'GET', path: '/events', description: 'SSE event stream for live updates' },
|
|
70
|
+
{ method: 'GET', path: '/api/sprints', description: 'List discovered sprints with claim counts' },
|
|
71
|
+
{ method: 'GET', path: '/api/analysis/:name', description: 'Full analysis of a single sprint' },
|
|
72
|
+
{ method: 'GET', path: '/api/calibration', description: 'Prediction calibration across all sprints' },
|
|
73
|
+
{ method: 'GET', path: '/api/decay', description: 'Find stale claims needing refresh (?days=N)' },
|
|
74
|
+
{ method: 'GET', path: '/api/dashboard', description: 'Combined analytics dashboard summary' },
|
|
75
|
+
{ method: 'GET', path: '/api/docs', description: 'This API documentation page' },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// ── Load existing CJS modules via createRequire ──────────────────────────────
|
|
79
|
+
|
|
80
|
+
const { analyze } = require('./analyzer.js');
|
|
81
|
+
const { measureVelocity } = require('./velocity.js');
|
|
82
|
+
const { checkDecay } = require('./decay.js');
|
|
83
|
+
const { calibrate } = require('./calibration.js');
|
|
84
|
+
const { loadSprints: loadDashboardSprints, buildHtml, claimsPaths } = require('./dashboard.js');
|
|
85
|
+
|
|
86
|
+
// ── Sprint discovery ─────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
function discoverSprints(rootDir) {
|
|
89
|
+
const sprints = [];
|
|
90
|
+
if (!existsSync(rootDir)) return sprints;
|
|
91
|
+
|
|
92
|
+
// Include root if it has claims.json
|
|
93
|
+
const directClaims = join(rootDir, 'claims.json');
|
|
94
|
+
if (existsSync(directClaims)) {
|
|
95
|
+
sprints.push(loadSingleSprint(rootDir));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Scan subdirectories (two levels deep to catch sprints/<name>/claims.json)
|
|
99
|
+
try {
|
|
100
|
+
const entries = readdirSync(rootDir, { withFileTypes: true });
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (!entry.isDirectory()) continue;
|
|
103
|
+
if (entry.name.startsWith('.')) continue;
|
|
104
|
+
const childDir = join(rootDir, entry.name);
|
|
105
|
+
const childClaims = join(childDir, 'claims.json');
|
|
106
|
+
if (existsSync(childClaims)) {
|
|
107
|
+
sprints.push(loadSingleSprint(childDir));
|
|
108
|
+
}
|
|
109
|
+
// Second level: scan subdirectories of this directory
|
|
110
|
+
try {
|
|
111
|
+
const subEntries = readdirSync(childDir, { withFileTypes: true });
|
|
112
|
+
for (const sub of subEntries) {
|
|
113
|
+
if (!sub.isDirectory()) continue;
|
|
114
|
+
if (sub.name.startsWith('.')) continue;
|
|
115
|
+
const subDir = join(childDir, sub.name);
|
|
116
|
+
const subClaims = join(subDir, 'claims.json');
|
|
117
|
+
if (existsSync(subClaims)) {
|
|
118
|
+
sprints.push(loadSingleSprint(subDir));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch { /* skip */ }
|
|
122
|
+
}
|
|
123
|
+
} catch { /* skip if unreadable */ }
|
|
124
|
+
|
|
125
|
+
return sprints;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function loadSingleSprint(dir) {
|
|
129
|
+
const sprint = {
|
|
130
|
+
name: basename(dir),
|
|
131
|
+
dir,
|
|
132
|
+
claims: [],
|
|
133
|
+
compilation: null,
|
|
134
|
+
gitLog: [],
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const claimsPath = join(dir, 'claims.json');
|
|
138
|
+
try {
|
|
139
|
+
const raw = JSON.parse(readFileSync(claimsPath, 'utf8'));
|
|
140
|
+
sprint.claims = Array.isArray(raw) ? raw : raw.claims || [];
|
|
141
|
+
} catch { /* skip */ }
|
|
142
|
+
|
|
143
|
+
const compilationPath = join(dir, 'compilation.json');
|
|
144
|
+
if (existsSync(compilationPath)) {
|
|
145
|
+
try {
|
|
146
|
+
sprint.compilation = JSON.parse(readFileSync(compilationPath, 'utf8'));
|
|
147
|
+
} catch { /* skip */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Git log for velocity
|
|
151
|
+
try {
|
|
152
|
+
const { execSync } = require('node:child_process');
|
|
153
|
+
sprint.gitLog = execSync(
|
|
154
|
+
`git log --oneline --format="%H|%ai|%s" -- claims.json`,
|
|
155
|
+
{ cwd: dir, encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
156
|
+
).trim().split('\n').filter(Boolean).map(line => {
|
|
157
|
+
const [hash, date, ...msg] = line.split('|');
|
|
158
|
+
return { hash, date, message: msg.join('|') };
|
|
159
|
+
});
|
|
160
|
+
} catch {
|
|
161
|
+
sprint.gitLog = [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return sprint;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── State ─────────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
let state = {
|
|
170
|
+
sprints: [],
|
|
171
|
+
lastRefresh: null,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const sseClients = new Set();
|
|
175
|
+
|
|
176
|
+
function broadcast(event) {
|
|
177
|
+
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
178
|
+
for (const res of sseClients) {
|
|
179
|
+
try { res.write(data); } catch { sseClients.delete(res); }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function refreshState() {
|
|
184
|
+
state.sprints = discoverSprints(ROOT);
|
|
185
|
+
state.lastRefresh = new Date().toISOString();
|
|
186
|
+
broadcast({ type: 'state', data: { sprintCount: state.sprints.length, lastRefresh: state.lastRefresh } });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── MIME types ────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
const MIME = {
|
|
192
|
+
'.html': 'text/html; charset=utf-8',
|
|
193
|
+
'.css': 'text/css; charset=utf-8',
|
|
194
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
195
|
+
'.json': 'application/json; charset=utf-8',
|
|
196
|
+
'.svg': 'image/svg+xml',
|
|
197
|
+
'.png': 'image/png',
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
function jsonResponse(res, code, data) {
|
|
203
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
204
|
+
res.end(JSON.stringify(data));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── SSE live-reload injection ────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
const SSE_SCRIPT = `
|
|
210
|
+
<script>
|
|
211
|
+
(function() {
|
|
212
|
+
var es, retryCount = 0;
|
|
213
|
+
var dot = document.getElementById('statusDot');
|
|
214
|
+
function connect() {
|
|
215
|
+
es = new EventSource('/events');
|
|
216
|
+
es.addEventListener('update', function() { location.reload(); });
|
|
217
|
+
es.onopen = function() {
|
|
218
|
+
retryCount = 0;
|
|
219
|
+
if (dot) dot.className = 'status-dot ok';
|
|
220
|
+
if (window._grainSetState) window._grainSetState('idle');
|
|
221
|
+
};
|
|
222
|
+
es.onerror = function() {
|
|
223
|
+
es.close();
|
|
224
|
+
if (dot) dot.className = 'status-dot';
|
|
225
|
+
if (window._grainSetState) window._grainSetState('orbit');
|
|
226
|
+
var delay = Math.min(30000, 1000 * Math.pow(2, retryCount)) + Math.random() * 1000;
|
|
227
|
+
retryCount++;
|
|
228
|
+
setTimeout(connect, delay);
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
connect();
|
|
232
|
+
})();
|
|
233
|
+
</script>`;
|
|
234
|
+
|
|
235
|
+
function injectSSE(html) {
|
|
236
|
+
return html.replace('</body>', SSE_SCRIPT + '\n</body>');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── HTTP server ───────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
const server = createServer(async (req, res) => {
|
|
242
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
243
|
+
|
|
244
|
+
// CORS (only when --cors is passed)
|
|
245
|
+
if (CORS_ORIGIN) {
|
|
246
|
+
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
|
|
247
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
248
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (req.method === 'OPTIONS' && CORS_ORIGIN) {
|
|
252
|
+
res.writeHead(204);
|
|
253
|
+
res.end();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
vlog('request', req.method, url.pathname);
|
|
258
|
+
|
|
259
|
+
// ── API: docs ──
|
|
260
|
+
if (req.method === 'GET' && url.pathname === '/api/docs') {
|
|
261
|
+
const html = `<!DOCTYPE html><html><head><title>harvest API</title>
|
|
262
|
+
<style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
|
|
263
|
+
table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
|
|
264
|
+
th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
|
|
265
|
+
<body><h1>harvest API</h1><p>${ROUTES.length} endpoints</p>
|
|
266
|
+
<table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
|
|
267
|
+
${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</code></td><td>'+r.description+'</td></tr>').join('')}
|
|
268
|
+
</table></body></html>`;
|
|
269
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
270
|
+
res.end(html);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── SSE endpoint ──
|
|
275
|
+
if (req.method === 'GET' && url.pathname === '/events') {
|
|
276
|
+
res.writeHead(200, {
|
|
277
|
+
'Content-Type': 'text/event-stream',
|
|
278
|
+
'Cache-Control': 'no-cache',
|
|
279
|
+
'Connection': 'keep-alive',
|
|
280
|
+
});
|
|
281
|
+
res.write(`data: ${JSON.stringify({ type: 'state', data: { sprintCount: state.sprints.length, lastRefresh: state.lastRefresh } })}\n\n`);
|
|
282
|
+
const heartbeat = setInterval(() => {
|
|
283
|
+
try { res.write(': heartbeat\n\n'); } catch { clearInterval(heartbeat); }
|
|
284
|
+
}, 15000);
|
|
285
|
+
sseClients.add(res);
|
|
286
|
+
vlog('sse', `client connected (${sseClients.size} total)`);
|
|
287
|
+
req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); vlog('sse', `client disconnected (${sseClients.size} total)`); });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── API: list sprints ──
|
|
292
|
+
if (req.method === 'GET' && url.pathname === '/api/sprints') {
|
|
293
|
+
refreshState();
|
|
294
|
+
const sprintList = state.sprints.map(s => ({
|
|
295
|
+
name: s.name,
|
|
296
|
+
dir: s.dir,
|
|
297
|
+
claimCount: s.claims.length,
|
|
298
|
+
hasCompilation: !!s.compilation,
|
|
299
|
+
gitCommits: s.gitLog.length,
|
|
300
|
+
}));
|
|
301
|
+
jsonResponse(res, 200, { sprints: sprintList });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── API: full analysis of a sprint ──
|
|
306
|
+
if (req.method === 'GET' && url.pathname.startsWith('/api/analysis/')) {
|
|
307
|
+
const sprintName = decodeURIComponent(url.pathname.slice('/api/analysis/'.length));
|
|
308
|
+
if (!sprintName) { jsonResponse(res, 400, { error: 'missing sprint name' }); return; }
|
|
309
|
+
|
|
310
|
+
// Refresh and find the sprint
|
|
311
|
+
refreshState();
|
|
312
|
+
const sprint = state.sprints.find(s => s.name === sprintName);
|
|
313
|
+
if (!sprint) { jsonResponse(res, 404, { error: `sprint "${sprintName}" not found` }); return; }
|
|
314
|
+
|
|
315
|
+
const analysis = analyze([sprint]);
|
|
316
|
+
const velocity = measureVelocity([sprint]);
|
|
317
|
+
|
|
318
|
+
jsonResponse(res, 200, {
|
|
319
|
+
sprint: sprintName,
|
|
320
|
+
analysis,
|
|
321
|
+
velocity,
|
|
322
|
+
});
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── API: calibration (all sprints) ──
|
|
327
|
+
if (req.method === 'GET' && url.pathname === '/api/calibration') {
|
|
328
|
+
refreshState();
|
|
329
|
+
if (state.sprints.length === 0) {
|
|
330
|
+
jsonResponse(res, 200, { calibration: { summary: { totalSprints: 0 }, sprints: [] } });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const result = calibrate(state.sprints);
|
|
334
|
+
jsonResponse(res, 200, { calibration: result });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── API: decay detection ──
|
|
339
|
+
if (req.method === 'GET' && url.pathname === '/api/decay') {
|
|
340
|
+
const days = parseInt(url.searchParams.get('days') || '30', 10);
|
|
341
|
+
refreshState();
|
|
342
|
+
if (state.sprints.length === 0) {
|
|
343
|
+
jsonResponse(res, 200, { decay: { summary: { totalClaims: 0 }, stale: [], decaying: [], unresolved: [] } });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const result = checkDecay(state.sprints, { thresholdDays: days });
|
|
347
|
+
jsonResponse(res, 200, { decay: result });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── API: dashboard summary (all sprints combined) ──
|
|
352
|
+
if (req.method === 'GET' && url.pathname === '/api/dashboard') {
|
|
353
|
+
refreshState();
|
|
354
|
+
if (state.sprints.length === 0) {
|
|
355
|
+
jsonResponse(res, 200, {
|
|
356
|
+
sprintCount: 0,
|
|
357
|
+
totalClaims: 0,
|
|
358
|
+
analysis: null,
|
|
359
|
+
velocity: null,
|
|
360
|
+
decay: null,
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const analysis = analyze(state.sprints);
|
|
365
|
+
const velocity = measureVelocity(state.sprints);
|
|
366
|
+
const decay = checkDecay(state.sprints, { thresholdDays: 30 });
|
|
367
|
+
|
|
368
|
+
jsonResponse(res, 200, {
|
|
369
|
+
sprintCount: state.sprints.length,
|
|
370
|
+
totalClaims: state.sprints.reduce((a, s) => a + s.claims.length, 0),
|
|
371
|
+
analysis,
|
|
372
|
+
velocity,
|
|
373
|
+
decay,
|
|
374
|
+
});
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── Dashboard UI (template-injected) ──
|
|
379
|
+
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
380
|
+
try {
|
|
381
|
+
const sprints = loadDashboardSprints(ROOT);
|
|
382
|
+
if (sprints.length === 0) {
|
|
383
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
384
|
+
res.end('<html><body style="background:#0a0e1a;color:#e2e8f0;font-family:monospace;padding:40px"><h1>No claims.json files found</h1><p>Watching for changes...</p>' + SSE_SCRIPT + '</body></html>');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const html = injectSSE(buildHtml(sprints));
|
|
388
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
389
|
+
res.end(html);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
392
|
+
res.end('Error building dashboard: ' + err.message);
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Static files (public/) ──
|
|
398
|
+
let filePath = url.pathname;
|
|
399
|
+
filePath = join(PUBLIC_DIR, filePath);
|
|
400
|
+
|
|
401
|
+
if (existsSync(filePath) && !statSync(filePath).isDirectory()) {
|
|
402
|
+
const ext = extname(filePath);
|
|
403
|
+
const mime = MIME[ext] || 'application/octet-stream';
|
|
404
|
+
try {
|
|
405
|
+
const content = readFileSync(filePath);
|
|
406
|
+
res.writeHead(200, { 'Content-Type': mime });
|
|
407
|
+
res.end(content);
|
|
408
|
+
} catch {
|
|
409
|
+
res.writeHead(500);
|
|
410
|
+
res.end('read error');
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── 404 ──
|
|
416
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
417
|
+
res.end('not found');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
421
|
+
const shutdown = (signal) => {
|
|
422
|
+
console.log(`\nharvest: ${signal} received, shutting down...`);
|
|
423
|
+
for (const res of sseClients) { try { res.end(); } catch {} }
|
|
424
|
+
sseClients.clear();
|
|
425
|
+
server.close(() => process.exit(0));
|
|
426
|
+
setTimeout(() => process.exit(1), 5000);
|
|
427
|
+
};
|
|
428
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
429
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
430
|
+
|
|
431
|
+
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
refreshState();
|
|
434
|
+
|
|
435
|
+
server.on('error', (err) => {
|
|
436
|
+
if (err.code === 'EADDRINUSE') {
|
|
437
|
+
console.error(`\nharvest: port ${PORT} is already in use.`);
|
|
438
|
+
console.error(` Try: harvest serve --port ${Number(PORT) + 1}`);
|
|
439
|
+
console.error(` Or stop the process using port ${PORT}.\n`);
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
throw err;
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ── File watching for live reload ────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
import { watch as fsWatch } from 'node:fs';
|
|
448
|
+
|
|
449
|
+
const watchers = [];
|
|
450
|
+
let debounceTimer = null;
|
|
451
|
+
|
|
452
|
+
function onClaimsChange() {
|
|
453
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
454
|
+
debounceTimer = setTimeout(() => {
|
|
455
|
+
refreshState();
|
|
456
|
+
// Send update event so SSE clients reload
|
|
457
|
+
const updateData = `event: update\ndata: ${JSON.stringify({ type: 'update' })}\n\n`;
|
|
458
|
+
for (const client of sseClients) {
|
|
459
|
+
try { client.write(updateData); } catch { sseClients.delete(client); }
|
|
460
|
+
}
|
|
461
|
+
}, 500);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function watchClaims() {
|
|
465
|
+
const paths = claimsPaths(ROOT);
|
|
466
|
+
for (const p of paths) {
|
|
467
|
+
try {
|
|
468
|
+
const w = fsWatch(p, { persistent: false }, () => onClaimsChange());
|
|
469
|
+
watchers.push(w);
|
|
470
|
+
} catch { /* file may not exist yet */ }
|
|
471
|
+
}
|
|
472
|
+
// Watch sprint directories for new claims files
|
|
473
|
+
for (const dir of [ROOT, join(ROOT, 'sprints'), join(ROOT, 'archive')]) {
|
|
474
|
+
if (!existsSync(dir)) continue;
|
|
475
|
+
try {
|
|
476
|
+
const w = fsWatch(dir, { persistent: false }, (_, filename) => {
|
|
477
|
+
if (filename && (filename === 'claims.json' || filename.includes('claims'))) {
|
|
478
|
+
onClaimsChange();
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
watchers.push(w);
|
|
482
|
+
} catch { /* ignore */ }
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
487
|
+
vlog('listen', `port=${PORT}`, `root=${ROOT}`);
|
|
488
|
+
console.log(`harvest: serving on http://localhost:${PORT}`);
|
|
489
|
+
console.log(` sprints: ${state.sprints.length} found`);
|
|
490
|
+
console.log(` root: ${ROOT}`);
|
|
491
|
+
watchClaims();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
export { server, PORT };
|
package/lib/templates.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* harvest -> barn edge: template discovery for report formatting.
|
|
5
|
+
*
|
|
6
|
+
* If barn is available (filesystem or HTTP), harvest can offer its
|
|
7
|
+
* templates as alternative report formats. Graceful fallback to
|
|
8
|
+
* harvest's built-in formatting when barn is not reachable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const http = require('node:http');
|
|
14
|
+
|
|
15
|
+
const BARN_PORT = 9093;
|
|
16
|
+
const BARN_SIBLINGS = [
|
|
17
|
+
path.join(__dirname, '..', '..', 'barn', 'templates'),
|
|
18
|
+
path.join(__dirname, '..', '..', '..', 'barn', 'templates'),
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Probe barn via filesystem (sibling checkout) or localhost API.
|
|
23
|
+
* Returns { available: true, templates: [...] } or { available: false }.
|
|
24
|
+
*/
|
|
25
|
+
function discoverTemplates() {
|
|
26
|
+
// Strategy 1: filesystem sibling
|
|
27
|
+
for (const dir of BARN_SIBLINGS) {
|
|
28
|
+
if (fs.existsSync(dir)) {
|
|
29
|
+
try {
|
|
30
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.html'));
|
|
31
|
+
const templates = files.map(f => {
|
|
32
|
+
const content = fs.readFileSync(path.join(dir, f), 'utf8');
|
|
33
|
+
const placeholders = [...new Set(content.match(/\{\{[A-Z_]+\}\}/g) || [])];
|
|
34
|
+
const commentMatch = content.match(/<!--\s*(.*?)\s*-->/);
|
|
35
|
+
return {
|
|
36
|
+
name: f.replace('.html', ''),
|
|
37
|
+
placeholders,
|
|
38
|
+
description: commentMatch ? commentMatch[1] : '',
|
|
39
|
+
source: 'filesystem',
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
return { available: true, templates, source: dir };
|
|
43
|
+
} catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { available: false, templates: [] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Async probe: try barn's HTTP API for template list.
|
|
53
|
+
* Falls back to filesystem discovery if the server is not running.
|
|
54
|
+
*/
|
|
55
|
+
function discoverTemplatesAsync() {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const req = http.get(`http://127.0.0.1:${BARN_PORT}/api/state`, { timeout: 2000 }, (res) => {
|
|
58
|
+
let body = '';
|
|
59
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
60
|
+
res.on('end', () => {
|
|
61
|
+
try {
|
|
62
|
+
const state = JSON.parse(body);
|
|
63
|
+
const templates = (state.templates || []).map(t => ({
|
|
64
|
+
name: t.name,
|
|
65
|
+
placeholders: t.placeholders || [],
|
|
66
|
+
description: t.description || '',
|
|
67
|
+
source: 'http',
|
|
68
|
+
}));
|
|
69
|
+
resolve({ available: true, templates, source: `http://127.0.0.1:${BARN_PORT}` });
|
|
70
|
+
} catch {
|
|
71
|
+
resolve(discoverTemplates());
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
req.on('error', () => resolve(discoverTemplates()));
|
|
76
|
+
req.on('timeout', () => { req.destroy(); resolve(discoverTemplates()); });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { discoverTemplates, discoverTemplatesAsync, BARN_PORT };
|