@grainulation/mill 1.0.0 → 1.0.1

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.
Files changed (41) hide show
  1. package/CODE_OF_CONDUCT.md +25 -0
  2. package/CONTRIBUTING.md +101 -0
  3. package/README.md +90 -42
  4. package/bin/mill.js +233 -67
  5. package/lib/exporters/csv.js +35 -30
  6. package/lib/exporters/json-ld.js +19 -13
  7. package/lib/exporters/markdown.js +83 -44
  8. package/lib/exporters/pdf.js +15 -15
  9. package/lib/formats/bibtex.js +41 -34
  10. package/lib/formats/changelog.js +27 -26
  11. package/lib/formats/confluence-adf.js +312 -0
  12. package/lib/formats/csv.js +41 -37
  13. package/lib/formats/dot.js +45 -34
  14. package/lib/formats/evidence-matrix.js +17 -16
  15. package/lib/formats/executive-summary.js +89 -41
  16. package/lib/formats/github-issues.js +40 -33
  17. package/lib/formats/graphml.js +45 -32
  18. package/lib/formats/html-report.js +110 -63
  19. package/lib/formats/jira-csv.js +30 -29
  20. package/lib/formats/json-ld.js +6 -6
  21. package/lib/formats/markdown.js +53 -36
  22. package/lib/formats/ndjson.js +6 -6
  23. package/lib/formats/obsidian.js +43 -35
  24. package/lib/formats/opml.js +38 -28
  25. package/lib/formats/ris.js +29 -23
  26. package/lib/formats/rss.js +31 -28
  27. package/lib/formats/sankey.js +16 -15
  28. package/lib/formats/slide-deck.js +145 -57
  29. package/lib/formats/sql.js +57 -53
  30. package/lib/formats/static-site.js +64 -52
  31. package/lib/formats/treemap.js +16 -15
  32. package/lib/formats/typescript-defs.js +79 -76
  33. package/lib/formats/yaml.js +58 -40
  34. package/lib/formats.js +16 -16
  35. package/lib/index.js +5 -5
  36. package/lib/json-ld-common.js +37 -31
  37. package/lib/publishers/clipboard.js +21 -19
  38. package/lib/publishers/static.js +27 -12
  39. package/lib/serve-mcp.js +158 -83
  40. package/lib/server.js +252 -142
  41. package/package.json +7 -3
package/lib/server.js CHANGED
@@ -10,25 +10,29 @@
10
10
  * mill serve [--port 9094] [--source /path/to/sprint]
11
11
  */
12
12
 
13
- import { createServer } from 'node:http';
14
- import { readFileSync, existsSync, statSync } from 'node:fs';
15
- import { readFile, stat } from 'node:fs/promises';
16
- import { join, resolve, extname, dirname } from 'node:path';
17
- import { fileURLToPath } from 'node:url';
18
- import { randomUUID } from 'node:crypto';
13
+ import { createServer } from "node:http";
14
+ import { readFileSync, existsSync, statSync } from "node:fs";
15
+ import { readFile, stat } from "node:fs/promises";
16
+ import { join, resolve, extname, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { randomUUID } from "node:crypto";
19
19
 
20
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
21
 
22
22
  // ── Crash handlers ──
23
- process.on('uncaughtException', (err) => {
24
- process.stderr.write(`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`);
23
+ process.on("uncaughtException", (err) => {
24
+ process.stderr.write(
25
+ `[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`,
26
+ );
25
27
  process.exit(1);
26
28
  });
27
- process.on('unhandledRejection', (reason) => {
28
- process.stderr.write(`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`);
29
+ process.on("unhandledRejection", (reason) => {
30
+ process.stderr.write(
31
+ `[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`,
32
+ );
29
33
  });
30
34
 
31
- const PUBLIC_DIR = join(__dirname, '..', 'public');
35
+ const PUBLIC_DIR = join(__dirname, "..", "public");
32
36
 
33
37
  // ── CLI args ──────────────────────────────────────────────────────────────────
34
38
 
@@ -38,44 +42,95 @@ function arg(name, fallback) {
38
42
  return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
39
43
  }
40
44
 
41
- const PORT = parseInt(arg('port', '9094'), 10);
42
- const SOURCE = resolve(arg('source', process.cwd()));
43
- const CORS_ORIGIN = arg('cors', null);
45
+ const PORT = parseInt(arg("port", "9094"), 10);
46
+ const SOURCE = resolve(arg("source", process.cwd()));
47
+ const CORS_ORIGIN = arg("cors", null);
44
48
 
45
49
  // ── Verbose logging ──────────────────────────────────────────────────────────
46
50
 
47
- const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
51
+ const verbose =
52
+ process.argv.includes("--verbose") || process.argv.includes("-v");
48
53
  function vlog(...a) {
49
54
  if (!verbose) return;
50
55
  const ts = new Date().toISOString();
51
- process.stderr.write(`[${ts}] mill: ${a.join(' ')}\n`);
56
+ process.stderr.write(`[${ts}] mill: ${a.join(" ")}\n`);
52
57
  }
53
58
 
54
59
  // ── Routes manifest ──────────────────────────────────────────────────────────
55
60
 
56
61
  const ROUTES = [
57
- { method: 'GET', path: '/events', description: 'SSE event stream for live updates' },
58
- { method: 'GET', path: '/api/state', description: 'Current state (source, formats, history)' },
59
- { method: 'GET', path: '/api/formats', description: 'List available export formats' },
60
- { method: 'POST', path: '/api/export', description: 'Export claims to a target format' },
61
- { method: 'GET', path: '/api/preview', description: 'Preview export output by ?format' },
62
- { method: 'GET', path: '/api/download', description: 'Download exported file by ?format' },
63
- { method: 'GET', path: '/api/history', description: 'Export job history' },
64
- { method: 'POST', path: '/api/refresh', description: 'Reload source data from disk' },
65
- { method: 'GET', path: '/api/docs', description: 'This API documentation page' },
66
- { method: 'GET', path: '/health', description: 'Health check endpoint' },
62
+ {
63
+ method: "GET",
64
+ path: "/events",
65
+ description: "SSE event stream for live updates",
66
+ },
67
+ {
68
+ method: "GET",
69
+ path: "/api/state",
70
+ description: "Current state (source, formats, history)",
71
+ },
72
+ {
73
+ method: "GET",
74
+ path: "/api/formats",
75
+ description: "List available export formats",
76
+ },
77
+ {
78
+ method: "POST",
79
+ path: "/api/export",
80
+ description: "Export claims to a target format",
81
+ },
82
+ {
83
+ method: "GET",
84
+ path: "/api/preview",
85
+ description: "Preview export output by ?format",
86
+ },
87
+ {
88
+ method: "GET",
89
+ path: "/api/download",
90
+ description: "Download exported file by ?format",
91
+ },
92
+ { method: "GET", path: "/api/history", description: "Export job history" },
93
+ {
94
+ method: "POST",
95
+ path: "/api/refresh",
96
+ description: "Reload source data from disk",
97
+ },
98
+ {
99
+ method: "GET",
100
+ path: "/api/docs",
101
+ description: "This API documentation page",
102
+ },
103
+ { method: "GET", path: "/health", description: "Health check endpoint" },
67
104
  ];
68
105
 
69
106
  // ── Format modules ────────────────────────────────────────────────────────────
70
107
 
71
108
  const formats = {};
72
109
  const formatModules = [
73
- 'markdown', 'csv', 'json-ld',
74
- 'html-report', 'executive-summary', 'slide-deck', 'ndjson',
75
- 'typescript-defs', 'yaml', 'sql', 'evidence-matrix',
76
- 'bibtex', 'ris', 'changelog', 'rss',
77
- 'jira-csv', 'github-issues', 'opml', 'obsidian',
78
- 'graphml', 'dot', 'treemap', 'sankey', 'static-site',
110
+ "markdown",
111
+ "csv",
112
+ "json-ld",
113
+ "html-report",
114
+ "executive-summary",
115
+ "slide-deck",
116
+ "ndjson",
117
+ "typescript-defs",
118
+ "yaml",
119
+ "sql",
120
+ "evidence-matrix",
121
+ "bibtex",
122
+ "ris",
123
+ "changelog",
124
+ "rss",
125
+ "jira-csv",
126
+ "github-issues",
127
+ "opml",
128
+ "obsidian",
129
+ "graphml",
130
+ "dot",
131
+ "treemap",
132
+ "sankey",
133
+ "static-site",
79
134
  ];
80
135
 
81
136
  for (const mod of formatModules) {
@@ -105,7 +160,11 @@ const sseClients = new Set();
105
160
  function broadcast(event) {
106
161
  const data = `data: ${JSON.stringify(event)}\n\n`;
107
162
  for (const res of sseClients) {
108
- try { res.write(data); } catch { sseClients.delete(res); }
163
+ try {
164
+ res.write(data);
165
+ } catch {
166
+ sseClients.delete(res);
167
+ }
109
168
  }
110
169
  }
111
170
 
@@ -113,54 +172,65 @@ function broadcast(event) {
113
172
 
114
173
  async function loadSource() {
115
174
  const files = {};
116
- vlog('read', `loading source from ${SOURCE}`);
175
+ vlog("read", `loading source from ${SOURCE}`);
117
176
 
118
177
  // Look for compilation.json
119
- const compilationPath = join(SOURCE, 'compilation.json');
178
+ const compilationPath = join(SOURCE, "compilation.json");
120
179
  if (existsSync(compilationPath)) {
121
180
  try {
122
- compilation = JSON.parse(await readFile(compilationPath, 'utf8'));
181
+ compilation = JSON.parse(await readFile(compilationPath, "utf8"));
123
182
  const s = await stat(compilationPath);
124
- files['compilation.json'] = {
183
+ files["compilation.json"] = {
125
184
  path: compilationPath,
126
185
  size: s.size,
127
186
  modified: s.mtime.toISOString(),
128
187
  };
129
- } catch (e) { compilation = null; vlog('warn', `failed to parse compilation.json: ${e.message}`); }
188
+ } catch (e) {
189
+ compilation = null;
190
+ vlog("warn", `failed to parse compilation.json: ${e.message}`);
191
+ }
130
192
  }
131
193
 
132
194
  // Look for claims.json
133
- const claimsPath = join(SOURCE, 'claims.json');
195
+ const claimsPath = join(SOURCE, "claims.json");
134
196
  if (existsSync(claimsPath)) {
135
197
  try {
136
- const raw = JSON.parse(await readFile(claimsPath, 'utf8'));
137
- claims = Array.isArray(raw) ? raw : (raw.claims || []);
198
+ const raw = JSON.parse(await readFile(claimsPath, "utf8"));
199
+ claims = Array.isArray(raw) ? raw : raw.claims || [];
138
200
  const s = await stat(claimsPath);
139
- files['claims.json'] = {
201
+ files["claims.json"] = {
140
202
  path: claimsPath,
141
203
  size: s.size,
142
204
  modified: s.mtime.toISOString(),
143
205
  };
144
- } catch (e) { claims = null; vlog('warn', `failed to parse claims.json: ${e.message}`); }
206
+ } catch (e) {
207
+ claims = null;
208
+ vlog("warn", `failed to parse claims.json: ${e.message}`);
209
+ }
145
210
  }
146
211
 
147
212
  // If compilation.json is the wheat compiler output (no .claims key),
148
213
  // or if there's no compilation at all, synthesize from claims.json
149
214
  if (claims) {
150
- const compilationHasClaims = compilation && Array.isArray(compilation.claims);
215
+ const compilationHasClaims =
216
+ compilation && Array.isArray(compilation.claims);
151
217
  if (!compilationHasClaims) {
152
218
  // Extract meta from claims.json if present
153
- const claimsRaw = JSON.parse(await readFile(join(SOURCE, 'claims.json'), 'utf8'));
219
+ const claimsRaw = JSON.parse(
220
+ await readFile(join(SOURCE, "claims.json"), "utf8"),
221
+ );
154
222
  const meta = claimsRaw.meta || {};
155
223
 
156
224
  // Merge compiler certificate if available
157
- const cert = compilation ? {
158
- compiled_at: compilation.compiled_at,
159
- sha256: compilation.claims_hash,
160
- claim_count: claims.length,
161
- compiler_version: compilation.compiler_version,
162
- status: compilation.status,
163
- } : {};
225
+ const cert = compilation
226
+ ? {
227
+ compiled_at: compilation.compiled_at,
228
+ sha256: compilation.claims_hash,
229
+ claim_count: claims.length,
230
+ compiler_version: compilation.compiler_version,
231
+ status: compilation.status,
232
+ }
233
+ : {};
164
234
 
165
235
  compilation = {
166
236
  meta,
@@ -178,7 +248,7 @@ function buildState() {
178
248
  return {
179
249
  source: SOURCE,
180
250
  sourceFiles,
181
- formats: Object.values(formats).map(f => ({
251
+ formats: Object.values(formats).map((f) => ({
182
252
  name: f.name,
183
253
  extension: f.extension,
184
254
  mimeType: f.mimeType,
@@ -198,7 +268,10 @@ function runExport(formatName, options = {}) {
198
268
  return { error: `Unknown format: ${formatName}` };
199
269
  }
200
270
  if (!compilation) {
201
- return { error: 'No compilation data available. Ensure compilation.json or claims.json exists in the source directory.' };
271
+ return {
272
+ error:
273
+ "No compilation data available. Ensure compilation.json or claims.json exists in the source directory.",
274
+ };
202
275
  }
203
276
 
204
277
  try {
@@ -212,7 +285,7 @@ function runExport(formatName, options = {}) {
212
285
  extension: fmt.extension,
213
286
  mimeType: fmt.mimeType,
214
287
  claimCount: compilation.claims?.length || 0,
215
- outputSize: Buffer.byteLength(output, 'utf8'),
288
+ outputSize: Buffer.byteLength(output, "utf8"),
216
289
  duration,
217
290
  timestamp: new Date().toISOString(),
218
291
  options,
@@ -222,7 +295,7 @@ function runExport(formatName, options = {}) {
222
295
  // Keep last 50 jobs
223
296
  if (exportHistory.length > 50) exportHistory.length = 50;
224
297
 
225
- broadcast({ type: 'export-complete', data: job });
298
+ broadcast({ type: "export-complete", data: job });
226
299
 
227
300
  return { job, output };
228
301
  } catch (err) {
@@ -233,13 +306,13 @@ function runExport(formatName, options = {}) {
233
306
  // ── MIME types ────────────────────────────────────────────────────────────────
234
307
 
235
308
  const MIME = {
236
- '.html': 'text/html; charset=utf-8',
237
- '.css': 'text/css; charset=utf-8',
238
- '.js': 'application/javascript; charset=utf-8',
239
- '.json': 'application/json; charset=utf-8',
240
- '.svg': 'image/svg+xml',
241
- '.png': 'image/png',
242
- '.ico': 'image/x-icon',
309
+ ".html": "text/html; charset=utf-8",
310
+ ".css": "text/css; charset=utf-8",
311
+ ".js": "application/javascript; charset=utf-8",
312
+ ".json": "application/json; charset=utf-8",
313
+ ".svg": "image/svg+xml",
314
+ ".png": "image/png",
315
+ ".ico": "image/x-icon",
243
316
  };
244
317
 
245
318
  // ── Body parser ───────────────────────────────────────────────────────────────
@@ -248,19 +321,23 @@ function readBody(req) {
248
321
  return new Promise((resolve, reject) => {
249
322
  const chunks = [];
250
323
  let size = 0;
251
- req.on('data', chunk => {
324
+ req.on("data", (chunk) => {
252
325
  size += chunk.length;
253
- if (size > 1048576) { resolve(null); req.destroy(); return; }
326
+ if (size > 1048576) {
327
+ resolve(null);
328
+ req.destroy();
329
+ return;
330
+ }
254
331
  chunks.push(chunk);
255
332
  });
256
- req.on('end', () => {
333
+ req.on("end", () => {
257
334
  try {
258
335
  resolve(JSON.parse(Buffer.concat(chunks).toString()));
259
336
  } catch {
260
337
  resolve(null);
261
338
  }
262
339
  });
263
- req.on('error', reject);
340
+ req.on("error", reject);
264
341
  });
265
342
  }
266
343
 
@@ -271,141 +348,161 @@ const server = createServer(async (req, res) => {
271
348
 
272
349
  // CORS (only when --cors is passed)
273
350
  if (CORS_ORIGIN) {
274
- res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
275
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
276
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
351
+ res.setHeader("Access-Control-Allow-Origin", CORS_ORIGIN);
352
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
353
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
277
354
  }
278
355
 
279
- if (req.method === 'OPTIONS' && CORS_ORIGIN) {
356
+ if (req.method === "OPTIONS" && CORS_ORIGIN) {
280
357
  res.writeHead(204);
281
358
  res.end();
282
359
  return;
283
360
  }
284
361
 
285
- vlog('request', req.method, url.pathname);
362
+ vlog("request", req.method, url.pathname);
286
363
 
287
364
  // ── Health check ──
288
- if (req.method === 'GET' && url.pathname === '/health') {
289
- res.writeHead(200, { 'Content-Type': 'application/json' });
290
- res.end(JSON.stringify({ status: 'ok', uptime: process.uptime(), formats: Object.keys(formats).length }));
365
+ if (req.method === "GET" && url.pathname === "/health") {
366
+ res.writeHead(200, { "Content-Type": "application/json" });
367
+ res.end(
368
+ JSON.stringify({
369
+ status: "ok",
370
+ uptime: process.uptime(),
371
+ formats: Object.keys(formats).length,
372
+ }),
373
+ );
291
374
  return;
292
375
  }
293
376
 
294
377
  // ── API: docs ──
295
- if (req.method === 'GET' && url.pathname === '/api/docs') {
378
+ if (req.method === "GET" && url.pathname === "/api/docs") {
296
379
  const html = `<!DOCTYPE html><html><head><title>mill API</title>
297
380
  <style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
298
381
  table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
299
382
  th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
300
383
  <body><h1>mill API</h1><p>${ROUTES.length} endpoints</p>
301
384
  <table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
302
- ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</code></td><td>'+r.description+'</td></tr>').join('')}
385
+ ${ROUTES.map((r) => "<tr><td><code>" + r.method + "</code></td><td><code>" + r.path + "</code></td><td>" + r.description + "</td></tr>").join("")}
303
386
  </table></body></html>`;
304
- res.writeHead(200, { 'Content-Type': 'text/html' });
387
+ res.writeHead(200, { "Content-Type": "text/html" });
305
388
  res.end(html);
306
389
  return;
307
390
  }
308
391
 
309
392
  // ── SSE endpoint ──
310
- if (req.method === 'GET' && url.pathname === '/events') {
393
+ if (req.method === "GET" && url.pathname === "/events") {
311
394
  res.writeHead(200, {
312
- 'Content-Type': 'text/event-stream',
313
- 'Cache-Control': 'no-cache',
314
- 'Connection': 'keep-alive',
395
+ "Content-Type": "text/event-stream",
396
+ "Cache-Control": "no-cache",
397
+ Connection: "keep-alive",
315
398
  });
316
- res.write(`data: ${JSON.stringify({ type: 'state', data: buildState() })}\n\n`);
399
+ res.write(
400
+ `data: ${JSON.stringify({ type: "state", data: buildState() })}\n\n`,
401
+ );
317
402
  const heartbeat = setInterval(() => {
318
- try { res.write(': heartbeat\n\n'); } catch { clearInterval(heartbeat); }
403
+ try {
404
+ res.write(": heartbeat\n\n");
405
+ } catch {
406
+ clearInterval(heartbeat);
407
+ }
319
408
  }, 15000);
320
409
  sseClients.add(res);
321
- vlog('sse', `client connected (${sseClients.size} total)`);
322
- req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); vlog('sse', `client disconnected (${sseClients.size} total)`); });
410
+ vlog("sse", `client connected (${sseClients.size} total)`);
411
+ req.on("close", () => {
412
+ clearInterval(heartbeat);
413
+ sseClients.delete(res);
414
+ vlog("sse", `client disconnected (${sseClients.size} total)`);
415
+ });
323
416
  return;
324
417
  }
325
418
 
326
419
  // ── API: state ──
327
- if (req.method === 'GET' && url.pathname === '/api/state') {
328
- res.writeHead(200, { 'Content-Type': 'application/json' });
420
+ if (req.method === "GET" && url.pathname === "/api/state") {
421
+ res.writeHead(200, { "Content-Type": "application/json" });
329
422
  res.end(JSON.stringify(buildState()));
330
423
  return;
331
424
  }
332
425
 
333
426
  // ── API: formats ──
334
- if (req.method === 'GET' && url.pathname === '/api/formats') {
335
- const formatList = Object.values(formats).map(f => ({
427
+ if (req.method === "GET" && url.pathname === "/api/formats") {
428
+ const formatList = Object.values(formats).map((f) => ({
336
429
  name: f.name,
337
430
  extension: f.extension,
338
431
  mimeType: f.mimeType,
339
432
  description: f.description,
340
- schema_version: '1.0.0',
433
+ schema_version: "1.0.0",
341
434
  }));
342
- res.writeHead(200, { 'Content-Type': 'application/json' });
435
+ res.writeHead(200, { "Content-Type": "application/json" });
343
436
  res.end(JSON.stringify({ formats: formatList }));
344
437
  return;
345
438
  }
346
439
 
347
440
  // ── API: export ──
348
- if (req.method === 'POST' && url.pathname === '/api/export') {
441
+ if (req.method === "POST" && url.pathname === "/api/export") {
349
442
  const body = await readBody(req);
350
443
  if (!body || !body.format) {
351
- res.writeHead(400, { 'Content-Type': 'application/json' });
444
+ res.writeHead(400, { "Content-Type": "application/json" });
352
445
  res.end(JSON.stringify({ error: 'Missing "format" in request body' }));
353
446
  return;
354
447
  }
355
448
 
356
449
  const result = runExport(body.format, body.options || {});
357
450
  if (result.error) {
358
- res.writeHead(400, { 'Content-Type': 'application/json' });
451
+ res.writeHead(400, { "Content-Type": "application/json" });
359
452
  res.end(JSON.stringify({ error: result.error }));
360
453
  return;
361
454
  }
362
455
 
363
- res.writeHead(200, { 'Content-Type': 'application/json' });
364
- res.end(JSON.stringify({
365
- job: result.job,
366
- output: result.output,
367
- }));
456
+ res.writeHead(200, { "Content-Type": "application/json" });
457
+ res.end(
458
+ JSON.stringify({
459
+ job: result.job,
460
+ output: result.output,
461
+ }),
462
+ );
368
463
  return;
369
464
  }
370
465
 
371
466
  // ── API: preview (GET with query param) ──
372
- if (req.method === 'GET' && url.pathname === '/api/preview') {
373
- const formatName = url.searchParams.get('format');
467
+ if (req.method === "GET" && url.pathname === "/api/preview") {
468
+ const formatName = url.searchParams.get("format");
374
469
  if (!formatName) {
375
- res.writeHead(400, { 'Content-Type': 'application/json' });
470
+ res.writeHead(400, { "Content-Type": "application/json" });
376
471
  res.end(JSON.stringify({ error: 'Missing "format" query parameter' }));
377
472
  return;
378
473
  }
379
474
 
380
475
  const result = runExport(formatName);
381
476
  if (result.error) {
382
- res.writeHead(400, { 'Content-Type': 'application/json' });
477
+ res.writeHead(400, { "Content-Type": "application/json" });
383
478
  res.end(JSON.stringify({ error: result.error }));
384
479
  return;
385
480
  }
386
481
 
387
- res.writeHead(200, { 'Content-Type': 'application/json' });
388
- res.end(JSON.stringify({
389
- format: formatName,
390
- output: result.output,
391
- size: result.job.outputSize,
392
- }));
482
+ res.writeHead(200, { "Content-Type": "application/json" });
483
+ res.end(
484
+ JSON.stringify({
485
+ format: formatName,
486
+ output: result.output,
487
+ size: result.job.outputSize,
488
+ }),
489
+ );
393
490
  return;
394
491
  }
395
492
 
396
493
  // ── API: download (returns the raw file) ──
397
- if (req.method === 'GET' && url.pathname === '/api/download') {
398
- const formatName = url.searchParams.get('format');
494
+ if (req.method === "GET" && url.pathname === "/api/download") {
495
+ const formatName = url.searchParams.get("format");
399
496
  if (!formatName) {
400
497
  res.writeHead(400);
401
- res.end('Missing format');
498
+ res.end("Missing format");
402
499
  return;
403
500
  }
404
501
 
405
502
  const fmt = formats[formatName];
406
503
  if (!fmt) {
407
504
  res.writeHead(400);
408
- res.end('Unknown format');
505
+ res.end("Unknown format");
409
506
  return;
410
507
  }
411
508
 
@@ -418,62 +515,69 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
418
515
 
419
516
  const filename = `export${fmt.extension}`;
420
517
  res.writeHead(200, {
421
- 'Content-Type': fmt.mimeType,
422
- 'Content-Disposition': `attachment; filename="${filename}"`,
518
+ "Content-Type": fmt.mimeType,
519
+ "Content-Disposition": `attachment; filename="${filename}"`,
423
520
  });
424
521
  res.end(result.output);
425
522
  return;
426
523
  }
427
524
 
428
525
  // ── API: history ──
429
- if (req.method === 'GET' && url.pathname === '/api/history') {
430
- res.writeHead(200, { 'Content-Type': 'application/json' });
526
+ if (req.method === "GET" && url.pathname === "/api/history") {
527
+ res.writeHead(200, { "Content-Type": "application/json" });
431
528
  res.end(JSON.stringify({ history: exportHistory }));
432
529
  return;
433
530
  }
434
531
 
435
532
  // ── API: refresh ──
436
- if (req.method === 'POST' && url.pathname === '/api/refresh') {
533
+ if (req.method === "POST" && url.pathname === "/api/refresh") {
437
534
  await loadSource();
438
- broadcast({ type: 'state', data: buildState() });
439
- res.writeHead(200, { 'Content-Type': 'application/json' });
535
+ broadcast({ type: "state", data: buildState() });
536
+ res.writeHead(200, { "Content-Type": "application/json" });
440
537
  res.end(JSON.stringify(buildState()));
441
538
  return;
442
539
  }
443
540
 
444
541
  // ── Static files ──
445
- let filePath = url.pathname === '/' ? '/index.html' : url.pathname;
542
+ let filePath = url.pathname === "/" ? "/index.html" : url.pathname;
446
543
 
447
544
  // Prevent directory traversal
448
- const resolved = resolve(PUBLIC_DIR, '.' + filePath);
545
+ const resolved = resolve(PUBLIC_DIR, "." + filePath);
449
546
  if (!resolved.startsWith(PUBLIC_DIR)) {
450
547
  res.writeHead(403);
451
- res.end('forbidden');
548
+ res.end("forbidden");
452
549
  return;
453
550
  }
454
551
 
455
552
  if (existsSync(resolved) && statSync(resolved).isFile()) {
456
553
  const ext = extname(resolved);
457
- res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
554
+ res.writeHead(200, {
555
+ "Content-Type": MIME[ext] || "application/octet-stream",
556
+ });
458
557
  res.end(readFileSync(resolved));
459
558
  return;
460
559
  }
461
560
 
462
561
  res.writeHead(404);
463
- res.end('not found');
562
+ res.end("not found");
464
563
  });
465
564
 
466
565
  // ── File watching (fingerprint-based polling) ─────────────────────────────────
467
566
 
468
- let lastFingerprint = '';
567
+ let lastFingerprint = "";
469
568
  function computeFingerprint() {
470
- const names = ['compilation.json', 'claims.json'];
569
+ const names = ["compilation.json", "claims.json"];
471
570
  const parts = [];
472
571
  for (const name of names) {
473
572
  const fp = join(SOURCE, name);
474
- try { const s = statSync(fp); parts.push(name + ':' + s.mtimeMs); } catch { /* skip */ }
573
+ try {
574
+ const s = statSync(fp);
575
+ parts.push(name + ":" + s.mtimeMs);
576
+ } catch {
577
+ /* skip */
578
+ }
475
579
  }
476
- return parts.join('|');
580
+ return parts.join("|");
477
581
  }
478
582
 
479
583
  function startWatcher() {
@@ -483,7 +587,7 @@ function startWatcher() {
483
587
  if (fp !== lastFingerprint) {
484
588
  lastFingerprint = fp;
485
589
  loadSource();
486
- broadcast({ type: 'source-changed', data: buildState() });
590
+ broadcast({ type: "source-changed", data: buildState() });
487
591
  }
488
592
  }, 2000);
489
593
  }
@@ -491,18 +595,22 @@ function startWatcher() {
491
595
  // ── Graceful shutdown ─────────────────────────────────────────────────────────
492
596
  const shutdown = (signal) => {
493
597
  console.log(`\nmill: ${signal} received, shutting down...`);
494
- for (const res of sseClients) { try { res.end(); } catch {} }
598
+ for (const res of sseClients) {
599
+ try {
600
+ res.end();
601
+ } catch {}
602
+ }
495
603
  sseClients.clear();
496
604
  server.close(() => process.exit(0));
497
605
  setTimeout(() => process.exit(1), 5000);
498
606
  };
499
- process.on('SIGTERM', () => shutdown('SIGTERM'));
500
- process.on('SIGINT', () => shutdown('SIGINT'));
607
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
608
+ process.on("SIGINT", () => shutdown("SIGINT"));
501
609
 
502
- server.on('error', (err) => {
503
- if (err.code === 'EADDRINUSE') {
610
+ server.on("error", (err) => {
611
+ if (err.code === "EADDRINUSE") {
504
612
  console.error(`mill: port ${PORT} is already in use. Try --port <other>.`);
505
- } else if (err.code === 'EACCES') {
613
+ } else if (err.code === "EACCES") {
506
614
  console.error(`mill: port ${PORT} requires elevated privileges.`);
507
615
  } else {
508
616
  console.error(`mill: server error: ${err.message}`);
@@ -514,22 +622,24 @@ server.on('error', (err) => {
514
622
 
515
623
  if (!existsSync(SOURCE)) {
516
624
  console.error(`mill: source directory not found: ${SOURCE}`);
517
- console.error(' Use --source <dir> to specify the sprint directory.');
625
+ console.error(" Use --source <dir> to specify the sprint directory.");
518
626
  process.exit(1);
519
627
  }
520
628
 
521
629
  await loadSource();
522
630
  startWatcher();
523
631
 
524
- server.listen(PORT, '127.0.0.1', () => {
525
- vlog('listen', `port=${PORT}`, `source=${SOURCE}`);
632
+ server.listen(PORT, "127.0.0.1", () => {
633
+ vlog("listen", `port=${PORT}`, `source=${SOURCE}`);
526
634
  console.log(`mill: serving on http://localhost:${PORT}`);
527
635
  console.log(` source: ${SOURCE}`);
528
- console.log(` formats: ${Object.keys(formats).join(', ')}`);
636
+ console.log(` formats: ${Object.keys(formats).join(", ")}`);
529
637
  if (compilation) {
530
638
  console.log(` claims: ${compilation.claims?.length || 0}`);
531
639
  } else {
532
640
  console.log(` claims: no compilation data found`);
533
641
  }
534
- console.log(` files: ${Object.keys(sourceFiles).join(', ') || 'none detected'}`);
642
+ console.log(
643
+ ` files: ${Object.keys(sourceFiles).join(", ") || "none detected"}`,
644
+ );
535
645
  });