@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.
- package/CODE_OF_CONDUCT.md +25 -0
- package/CONTRIBUTING.md +101 -0
- package/README.md +90 -42
- package/bin/mill.js +233 -67
- package/lib/exporters/csv.js +35 -30
- package/lib/exporters/json-ld.js +19 -13
- package/lib/exporters/markdown.js +83 -44
- package/lib/exporters/pdf.js +15 -15
- package/lib/formats/bibtex.js +41 -34
- package/lib/formats/changelog.js +27 -26
- package/lib/formats/confluence-adf.js +312 -0
- package/lib/formats/csv.js +41 -37
- package/lib/formats/dot.js +45 -34
- package/lib/formats/evidence-matrix.js +17 -16
- package/lib/formats/executive-summary.js +89 -41
- package/lib/formats/github-issues.js +40 -33
- package/lib/formats/graphml.js +45 -32
- package/lib/formats/html-report.js +110 -63
- package/lib/formats/jira-csv.js +30 -29
- package/lib/formats/json-ld.js +6 -6
- package/lib/formats/markdown.js +53 -36
- package/lib/formats/ndjson.js +6 -6
- package/lib/formats/obsidian.js +43 -35
- package/lib/formats/opml.js +38 -28
- package/lib/formats/ris.js +29 -23
- package/lib/formats/rss.js +31 -28
- package/lib/formats/sankey.js +16 -15
- package/lib/formats/slide-deck.js +145 -57
- package/lib/formats/sql.js +57 -53
- package/lib/formats/static-site.js +64 -52
- package/lib/formats/treemap.js +16 -15
- package/lib/formats/typescript-defs.js +79 -76
- package/lib/formats/yaml.js +58 -40
- package/lib/formats.js +16 -16
- package/lib/index.js +5 -5
- package/lib/json-ld-common.js +37 -31
- package/lib/publishers/clipboard.js +21 -19
- package/lib/publishers/static.js +27 -12
- package/lib/serve-mcp.js +158 -83
- package/lib/server.js +252 -142
- 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
|
|
14
|
-
import { readFileSync, existsSync, statSync } from
|
|
15
|
-
import { readFile, stat } from
|
|
16
|
-
import { join, resolve, extname, dirname } from
|
|
17
|
-
import { fileURLToPath } from
|
|
18
|
-
import { randomUUID } from
|
|
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(
|
|
24
|
-
process.stderr.write(
|
|
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(
|
|
28
|
-
process.stderr.write(
|
|
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,
|
|
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(
|
|
42
|
-
const SOURCE = resolve(arg(
|
|
43
|
-
const CORS_ORIGIN = arg(
|
|
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 =
|
|
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(
|
|
56
|
+
process.stderr.write(`[${ts}] mill: ${a.join(" ")}\n`);
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
// ── Routes manifest ──────────────────────────────────────────────────────────
|
|
55
60
|
|
|
56
61
|
const ROUTES = [
|
|
57
|
-
{
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
{
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 {
|
|
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(
|
|
175
|
+
vlog("read", `loading source from ${SOURCE}`);
|
|
117
176
|
|
|
118
177
|
// Look for compilation.json
|
|
119
|
-
const compilationPath = join(SOURCE,
|
|
178
|
+
const compilationPath = join(SOURCE, "compilation.json");
|
|
120
179
|
if (existsSync(compilationPath)) {
|
|
121
180
|
try {
|
|
122
|
-
compilation = JSON.parse(await readFile(compilationPath,
|
|
181
|
+
compilation = JSON.parse(await readFile(compilationPath, "utf8"));
|
|
123
182
|
const s = await stat(compilationPath);
|
|
124
|
-
files[
|
|
183
|
+
files["compilation.json"] = {
|
|
125
184
|
path: compilationPath,
|
|
126
185
|
size: s.size,
|
|
127
186
|
modified: s.mtime.toISOString(),
|
|
128
187
|
};
|
|
129
|
-
} catch (e) {
|
|
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,
|
|
195
|
+
const claimsPath = join(SOURCE, "claims.json");
|
|
134
196
|
if (existsSync(claimsPath)) {
|
|
135
197
|
try {
|
|
136
|
-
const raw = JSON.parse(await readFile(claimsPath,
|
|
137
|
-
claims = Array.isArray(raw) ? raw :
|
|
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[
|
|
201
|
+
files["claims.json"] = {
|
|
140
202
|
path: claimsPath,
|
|
141
203
|
size: s.size,
|
|
142
204
|
modified: s.mtime.toISOString(),
|
|
143
205
|
};
|
|
144
|
-
} catch (e) {
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 {
|
|
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,
|
|
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:
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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(
|
|
324
|
+
req.on("data", (chunk) => {
|
|
252
325
|
size += chunk.length;
|
|
253
|
-
if (size > 1048576) {
|
|
326
|
+
if (size > 1048576) {
|
|
327
|
+
resolve(null);
|
|
328
|
+
req.destroy();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
254
331
|
chunks.push(chunk);
|
|
255
332
|
});
|
|
256
|
-
req.on(
|
|
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(
|
|
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(
|
|
275
|
-
res.setHeader(
|
|
276
|
-
res.setHeader(
|
|
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 ===
|
|
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(
|
|
362
|
+
vlog("request", req.method, url.pathname);
|
|
286
363
|
|
|
287
364
|
// ── Health check ──
|
|
288
|
-
if (req.method ===
|
|
289
|
-
res.writeHead(200, {
|
|
290
|
-
res.end(
|
|
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 ===
|
|
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 =>
|
|
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, {
|
|
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 ===
|
|
393
|
+
if (req.method === "GET" && url.pathname === "/events") {
|
|
311
394
|
res.writeHead(200, {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
395
|
+
"Content-Type": "text/event-stream",
|
|
396
|
+
"Cache-Control": "no-cache",
|
|
397
|
+
Connection: "keep-alive",
|
|
315
398
|
});
|
|
316
|
-
res.write(
|
|
399
|
+
res.write(
|
|
400
|
+
`data: ${JSON.stringify({ type: "state", data: buildState() })}\n\n`,
|
|
401
|
+
);
|
|
317
402
|
const heartbeat = setInterval(() => {
|
|
318
|
-
try {
|
|
403
|
+
try {
|
|
404
|
+
res.write(": heartbeat\n\n");
|
|
405
|
+
} catch {
|
|
406
|
+
clearInterval(heartbeat);
|
|
407
|
+
}
|
|
319
408
|
}, 15000);
|
|
320
409
|
sseClients.add(res);
|
|
321
|
-
vlog(
|
|
322
|
-
req.on(
|
|
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 ===
|
|
328
|
-
res.writeHead(200, {
|
|
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 ===
|
|
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:
|
|
433
|
+
schema_version: "1.0.0",
|
|
341
434
|
}));
|
|
342
|
-
res.writeHead(200, {
|
|
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 ===
|
|
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, {
|
|
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, {
|
|
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, {
|
|
364
|
-
res.end(
|
|
365
|
-
|
|
366
|
-
|
|
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 ===
|
|
373
|
-
const formatName = url.searchParams.get(
|
|
467
|
+
if (req.method === "GET" && url.pathname === "/api/preview") {
|
|
468
|
+
const formatName = url.searchParams.get("format");
|
|
374
469
|
if (!formatName) {
|
|
375
|
-
res.writeHead(400, {
|
|
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, {
|
|
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, {
|
|
388
|
-
res.end(
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 ===
|
|
398
|
-
const formatName = url.searchParams.get(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
422
|
-
|
|
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 ===
|
|
430
|
-
res.writeHead(200, {
|
|
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 ===
|
|
533
|
+
if (req.method === "POST" && url.pathname === "/api/refresh") {
|
|
437
534
|
await loadSource();
|
|
438
|
-
broadcast({ type:
|
|
439
|
-
res.writeHead(200, {
|
|
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 ===
|
|
542
|
+
let filePath = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
446
543
|
|
|
447
544
|
// Prevent directory traversal
|
|
448
|
-
const resolved = resolve(PUBLIC_DIR,
|
|
545
|
+
const resolved = resolve(PUBLIC_DIR, "." + filePath);
|
|
449
546
|
if (!resolved.startsWith(PUBLIC_DIR)) {
|
|
450
547
|
res.writeHead(403);
|
|
451
|
-
res.end(
|
|
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, {
|
|
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(
|
|
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 = [
|
|
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 {
|
|
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:
|
|
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) {
|
|
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(
|
|
500
|
-
process.on(
|
|
607
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
608
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
501
609
|
|
|
502
|
-
server.on(
|
|
503
|
-
if (err.code ===
|
|
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 ===
|
|
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(
|
|
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,
|
|
525
|
-
vlog(
|
|
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(
|
|
642
|
+
console.log(
|
|
643
|
+
` files: ${Object.keys(sourceFiles).join(", ") || "none detected"}`,
|
|
644
|
+
);
|
|
535
645
|
});
|