@grainulation/barn 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 +97 -0
- package/README.md +46 -29
- package/bin/barn.js +30 -23
- package/lib/index.js +41 -25
- package/lib/server.js +211 -114
- package/package.json +8 -4
- package/public/grainulation-icons.svg +47 -0
- package/public/grainulation-tokens.css +78 -18
- package/public/status-icons.svg +40 -0
- package/templates/README.md +4 -0
- package/templates/adr.json +1 -3
- package/templates/brief.json +1 -4
- package/templates/certificate.json +7 -1
- package/templates/comparison.json +1 -3
- package/templates/dashboard.json +1 -4
- package/templates/email-digest.html +1 -1
- package/templates/email-digest.json +1 -3
- package/templates/evidence-matrix.json +1 -3
- package/templates/explainer.json +1 -4
- package/templates/handoff.json +1 -3
- package/templates/rfc.json +1 -4
- package/templates/risk-register.json +1 -4
- package/templates/slide-deck.json +1 -3
- package/templates/template.schema.json +20 -4
- package/tools/README.md +5 -2
- package/tools/build-pdf.js +6 -6
- package/tools/detect-sprints.js +78 -54
- package/tools/generate-manifest.js +78 -43
package/lib/server.js
CHANGED
|
@@ -10,29 +10,35 @@
|
|
|
10
10
|
* barn serve [--port 9093] [--root /path/to/repo]
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { createServer } from
|
|
14
|
-
import { readFileSync, existsSync, statSync } from
|
|
15
|
-
import { readFile, stat, readdir } from
|
|
16
|
-
import { join, resolve, extname, dirname, basename } from
|
|
17
|
-
import { fileURLToPath } from
|
|
18
|
-
import { execFile } from
|
|
19
|
-
import { loadTemplates as _loadTemplates } from
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
15
|
+
import { readFile, stat, readdir } from "node:fs/promises";
|
|
16
|
+
import { join, resolve, extname, dirname, basename } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
19
|
+
import { loadTemplates as _loadTemplates } from "./index.js";
|
|
20
20
|
|
|
21
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
22
|
|
|
23
23
|
// ── Crash handlers ──
|
|
24
|
-
process.on(
|
|
25
|
-
process.stderr.write(
|
|
24
|
+
process.on("uncaughtException", (err) => {
|
|
25
|
+
process.stderr.write(
|
|
26
|
+
`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`,
|
|
27
|
+
);
|
|
26
28
|
process.exit(1);
|
|
27
29
|
});
|
|
28
|
-
process.on(
|
|
29
|
-
process.stderr.write(
|
|
30
|
+
process.on("unhandledRejection", (reason) => {
|
|
31
|
+
process.stderr.write(
|
|
32
|
+
`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`,
|
|
33
|
+
);
|
|
30
34
|
});
|
|
31
35
|
|
|
32
|
-
const PKG = JSON.parse(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
+
const PKG = JSON.parse(
|
|
37
|
+
readFileSync(join(__dirname, "..", "package.json"), "utf8"),
|
|
38
|
+
);
|
|
39
|
+
const PUBLIC_DIR = join(__dirname, "..", "public");
|
|
40
|
+
const TEMPLATES_DIR = join(__dirname, "..", "templates");
|
|
41
|
+
const TOOLS_DIR = join(__dirname, "..", "tools");
|
|
36
42
|
|
|
37
43
|
// ── CLI args ──────────────────────────────────────────────────────────────────
|
|
38
44
|
|
|
@@ -42,29 +48,59 @@ function arg(name, fallback) {
|
|
|
42
48
|
return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
const PORT = parseInt(arg(
|
|
46
|
-
const ROOT = resolve(arg(
|
|
47
|
-
const CORS_ORIGIN = arg(
|
|
51
|
+
const PORT = parseInt(arg("port", "9093"), 10);
|
|
52
|
+
const ROOT = resolve(arg("root", process.cwd()));
|
|
53
|
+
const CORS_ORIGIN = arg("cors", null);
|
|
48
54
|
|
|
49
55
|
// ── Verbose logging ──────────────────────────────────────────────────────────
|
|
50
56
|
|
|
51
|
-
const verbose =
|
|
57
|
+
const verbose =
|
|
58
|
+
process.argv.includes("--verbose") || process.argv.includes("-v");
|
|
52
59
|
function vlog(...a) {
|
|
53
60
|
if (!verbose) return;
|
|
54
61
|
const ts = new Date().toISOString();
|
|
55
|
-
process.stderr.write(`[${ts}] barn: ${a.join(
|
|
62
|
+
process.stderr.write(`[${ts}] barn: ${a.join(" ")}\n`);
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
// ── Routes manifest ──────────────────────────────────────────────────────────
|
|
59
66
|
|
|
60
67
|
const ROUTES = [
|
|
61
|
-
{
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
{
|
|
67
|
-
|
|
68
|
+
{
|
|
69
|
+
method: "GET",
|
|
70
|
+
path: "/health",
|
|
71
|
+
description: "Health check (tool, version, port, uptime)",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
method: "GET",
|
|
75
|
+
path: "/events",
|
|
76
|
+
description: "SSE event stream for live updates",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
method: "GET",
|
|
80
|
+
path: "/api/state",
|
|
81
|
+
description: "Current state (templates, sprints, manifest)",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
method: "GET",
|
|
85
|
+
path: "/api/template",
|
|
86
|
+
description: "Template content by ?name parameter",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
method: "GET",
|
|
90
|
+
path: "/api/search",
|
|
91
|
+
description:
|
|
92
|
+
"Search templates by ?q=<query> (name, description, placeholders, features)",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
method: "POST",
|
|
96
|
+
path: "/api/refresh",
|
|
97
|
+
description: "Refresh state from disk",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
method: "GET",
|
|
101
|
+
path: "/api/docs",
|
|
102
|
+
description: "This API documentation page",
|
|
103
|
+
},
|
|
68
104
|
];
|
|
69
105
|
|
|
70
106
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
@@ -81,44 +117,58 @@ const sseClients = new Set();
|
|
|
81
117
|
function broadcast(event) {
|
|
82
118
|
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
83
119
|
for (const res of sseClients) {
|
|
84
|
-
try {
|
|
120
|
+
try {
|
|
121
|
+
res.write(data);
|
|
122
|
+
} catch {
|
|
123
|
+
sseClients.delete(res);
|
|
124
|
+
}
|
|
85
125
|
}
|
|
86
126
|
}
|
|
87
127
|
|
|
88
128
|
// ── Data loading ──────────────────────────────────────────────────────────────
|
|
89
129
|
|
|
90
130
|
function loadTemplates() {
|
|
91
|
-
vlog(
|
|
131
|
+
vlog("read", TEMPLATES_DIR);
|
|
92
132
|
return _loadTemplates(TEMPLATES_DIR);
|
|
93
133
|
}
|
|
94
134
|
|
|
95
135
|
function loadSprints() {
|
|
96
|
-
const mod = join(TOOLS_DIR,
|
|
136
|
+
const mod = join(TOOLS_DIR, "detect-sprints.js");
|
|
97
137
|
if (!existsSync(mod)) return Promise.resolve({ sprints: [], active: null });
|
|
98
138
|
|
|
99
139
|
return new Promise((resolve) => {
|
|
100
|
-
execFile(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
140
|
+
execFile(
|
|
141
|
+
"node",
|
|
142
|
+
[mod, "--json", "--root", ROOT],
|
|
143
|
+
{
|
|
144
|
+
timeout: 10000,
|
|
145
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
146
|
+
},
|
|
147
|
+
(err, stdout) => {
|
|
148
|
+
if (err) {
|
|
149
|
+
resolve({ sprints: [], active: null });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const data = JSON.parse(stdout);
|
|
154
|
+
resolve({
|
|
155
|
+
sprints: data.sprints || [],
|
|
156
|
+
active:
|
|
157
|
+
(data.sprints || []).find((s) => s.status === "active") || null,
|
|
158
|
+
});
|
|
159
|
+
} catch {
|
|
160
|
+
resolve({ sprints: [], active: null });
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
);
|
|
114
164
|
});
|
|
115
165
|
}
|
|
116
166
|
|
|
117
167
|
function loadManifest() {
|
|
118
|
-
const manifestPath = join(ROOT,
|
|
168
|
+
const manifestPath = join(ROOT, "wheat-manifest.json");
|
|
119
169
|
if (!existsSync(manifestPath)) return null;
|
|
120
170
|
try {
|
|
121
|
-
return JSON.parse(readFileSync(manifestPath,
|
|
171
|
+
return JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
122
172
|
} catch {
|
|
123
173
|
return null;
|
|
124
174
|
}
|
|
@@ -133,20 +183,24 @@ async function refreshState() {
|
|
|
133
183
|
state.sprints = sprintData.sprints;
|
|
134
184
|
state.activeSprint = sprintData.active;
|
|
135
185
|
state.manifest = loadManifest();
|
|
136
|
-
broadcast({ type:
|
|
186
|
+
broadcast({ type: "state", data: state });
|
|
137
187
|
})();
|
|
138
|
-
try {
|
|
188
|
+
try {
|
|
189
|
+
return await refreshPending;
|
|
190
|
+
} finally {
|
|
191
|
+
refreshPending = null;
|
|
192
|
+
}
|
|
139
193
|
}
|
|
140
194
|
|
|
141
195
|
// ── MIME types ────────────────────────────────────────────────────────────────
|
|
142
196
|
|
|
143
197
|
const MIME = {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
198
|
+
".html": "text/html; charset=utf-8",
|
|
199
|
+
".css": "text/css; charset=utf-8",
|
|
200
|
+
".js": "application/javascript; charset=utf-8",
|
|
201
|
+
".json": "application/json; charset=utf-8",
|
|
202
|
+
".svg": "image/svg+xml",
|
|
203
|
+
".png": "image/png",
|
|
150
204
|
};
|
|
151
205
|
|
|
152
206
|
// ── HTTP server ───────────────────────────────────────────────────────────────
|
|
@@ -156,74 +210,89 @@ const server = createServer(async (req, res) => {
|
|
|
156
210
|
|
|
157
211
|
// CORS headers (only when --cors is passed)
|
|
158
212
|
if (CORS_ORIGIN) {
|
|
159
|
-
res.setHeader(
|
|
160
|
-
res.setHeader(
|
|
161
|
-
res.setHeader(
|
|
213
|
+
res.setHeader("Access-Control-Allow-Origin", CORS_ORIGIN);
|
|
214
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
215
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
162
216
|
}
|
|
163
217
|
|
|
164
|
-
if (req.method ===
|
|
218
|
+
if (req.method === "OPTIONS" && CORS_ORIGIN) {
|
|
165
219
|
res.writeHead(204);
|
|
166
220
|
res.end();
|
|
167
221
|
return;
|
|
168
222
|
}
|
|
169
223
|
|
|
170
|
-
vlog(
|
|
224
|
+
vlog("request", req.method, url.pathname);
|
|
171
225
|
|
|
172
226
|
// ── Health check ──
|
|
173
|
-
if (req.method ===
|
|
174
|
-
res.writeHead(200, {
|
|
175
|
-
res.end(
|
|
227
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
228
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
229
|
+
res.end(
|
|
230
|
+
JSON.stringify({
|
|
231
|
+
tool: "barn",
|
|
232
|
+
version: PKG.version,
|
|
233
|
+
port: PORT,
|
|
234
|
+
uptime: process.uptime(),
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
176
237
|
return;
|
|
177
238
|
}
|
|
178
239
|
|
|
179
240
|
// ── API: docs ──
|
|
180
|
-
if (req.method ===
|
|
241
|
+
if (req.method === "GET" && url.pathname === "/api/docs") {
|
|
181
242
|
const html = `<!DOCTYPE html><html><head><title>barn API</title>
|
|
182
243
|
<style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
|
|
183
244
|
table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
|
|
184
245
|
th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
|
|
185
246
|
<body><h1>barn API</h1><p>${ROUTES.length} endpoints</p>
|
|
186
247
|
<table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
|
|
187
|
-
${ROUTES.map(r =>
|
|
248
|
+
${ROUTES.map((r) => "<tr><td><code>" + r.method + "</code></td><td><code>" + r.path + "</code></td><td>" + r.description + "</td></tr>").join("")}
|
|
188
249
|
</table></body></html>`;
|
|
189
|
-
res.writeHead(200, {
|
|
250
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
190
251
|
res.end(html);
|
|
191
252
|
return;
|
|
192
253
|
}
|
|
193
254
|
|
|
194
255
|
// ── SSE endpoint ──
|
|
195
|
-
if (req.method ===
|
|
256
|
+
if (req.method === "GET" && url.pathname === "/events") {
|
|
196
257
|
res.writeHead(200, {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
258
|
+
"Content-Type": "text/event-stream",
|
|
259
|
+
"Cache-Control": "no-cache",
|
|
260
|
+
Connection: "keep-alive",
|
|
200
261
|
});
|
|
201
|
-
res.write(`data: ${JSON.stringify({ type:
|
|
262
|
+
res.write(`data: ${JSON.stringify({ type: "state", data: state })}\n\n`);
|
|
202
263
|
const heartbeat = setInterval(() => {
|
|
203
|
-
try {
|
|
264
|
+
try {
|
|
265
|
+
res.write(": heartbeat\n\n");
|
|
266
|
+
} catch {
|
|
267
|
+
clearInterval(heartbeat);
|
|
268
|
+
}
|
|
204
269
|
}, 15000);
|
|
205
270
|
sseClients.add(res);
|
|
206
|
-
vlog(
|
|
207
|
-
req.on(
|
|
271
|
+
vlog("sse", `client connected (${sseClients.size} total)`);
|
|
272
|
+
req.on("close", () => {
|
|
273
|
+
clearInterval(heartbeat);
|
|
274
|
+
sseClients.delete(res);
|
|
275
|
+
vlog("sse", `client disconnected (${sseClients.size} total)`);
|
|
276
|
+
});
|
|
208
277
|
return;
|
|
209
278
|
}
|
|
210
279
|
|
|
211
280
|
// ── API: state ──
|
|
212
|
-
if (req.method ===
|
|
213
|
-
res.writeHead(200, {
|
|
281
|
+
if (req.method === "GET" && url.pathname === "/api/state") {
|
|
282
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
214
283
|
res.end(JSON.stringify(state));
|
|
215
284
|
return;
|
|
216
285
|
}
|
|
217
286
|
|
|
218
287
|
// ── API: search templates ──
|
|
219
|
-
if (req.method ===
|
|
220
|
-
const q = (url.searchParams.get(
|
|
288
|
+
if (req.method === "GET" && url.pathname === "/api/search") {
|
|
289
|
+
const q = (url.searchParams.get("q") || "").toLowerCase().trim();
|
|
221
290
|
if (!q) {
|
|
222
|
-
res.writeHead(200, {
|
|
291
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
223
292
|
res.end(JSON.stringify(state.templates));
|
|
224
293
|
return;
|
|
225
294
|
}
|
|
226
|
-
const filtered = state.templates.filter(tpl => {
|
|
295
|
+
const filtered = state.templates.filter((tpl) => {
|
|
227
296
|
const haystack = [
|
|
228
297
|
tpl.name,
|
|
229
298
|
tpl.title,
|
|
@@ -231,83 +300,103 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
231
300
|
...tpl.placeholders,
|
|
232
301
|
...tpl.features,
|
|
233
302
|
...tpl.tags,
|
|
234
|
-
]
|
|
303
|
+
]
|
|
304
|
+
.join(" ")
|
|
305
|
+
.toLowerCase();
|
|
235
306
|
return haystack.includes(q);
|
|
236
307
|
});
|
|
237
|
-
res.writeHead(200, {
|
|
308
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
238
309
|
res.end(JSON.stringify(filtered));
|
|
239
310
|
return;
|
|
240
311
|
}
|
|
241
312
|
|
|
242
313
|
// ── API: template content ──
|
|
243
|
-
if (req.method ===
|
|
244
|
-
const name = url.searchParams.get(
|
|
245
|
-
if (!name) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
314
|
+
if (req.method === "GET" && url.pathname === "/api/template") {
|
|
315
|
+
const name = url.searchParams.get("name");
|
|
316
|
+
if (!name) {
|
|
317
|
+
res.writeHead(400);
|
|
318
|
+
res.end("missing name");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const filePath = resolve(TEMPLATES_DIR, name + ".html");
|
|
322
|
+
if (!filePath.startsWith(TEMPLATES_DIR)) {
|
|
323
|
+
res.writeHead(403);
|
|
324
|
+
res.end("forbidden");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (!existsSync(filePath)) {
|
|
328
|
+
res.writeHead(404);
|
|
329
|
+
res.end("not found");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
333
|
+
res.end(readFileSync(filePath, "utf8"));
|
|
251
334
|
return;
|
|
252
335
|
}
|
|
253
336
|
|
|
254
337
|
// ── API: refresh ──
|
|
255
|
-
if (req.method ===
|
|
338
|
+
if (req.method === "POST" && url.pathname === "/api/refresh") {
|
|
256
339
|
await refreshState();
|
|
257
|
-
res.writeHead(200, {
|
|
340
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
258
341
|
res.end(JSON.stringify(state));
|
|
259
342
|
return;
|
|
260
343
|
}
|
|
261
344
|
|
|
262
345
|
// ── Static files ──
|
|
263
|
-
let filePath = url.pathname ===
|
|
346
|
+
let filePath = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
264
347
|
|
|
265
348
|
// Prevent directory traversal
|
|
266
|
-
const resolved = resolve(PUBLIC_DIR,
|
|
349
|
+
const resolved = resolve(PUBLIC_DIR, "." + filePath);
|
|
267
350
|
if (!resolved.startsWith(PUBLIC_DIR)) {
|
|
268
351
|
res.writeHead(403);
|
|
269
|
-
res.end(
|
|
352
|
+
res.end("forbidden");
|
|
270
353
|
return;
|
|
271
354
|
}
|
|
272
355
|
|
|
273
356
|
if (existsSync(resolved) && statSync(resolved).isFile()) {
|
|
274
357
|
const ext = extname(resolved);
|
|
275
|
-
res.writeHead(200, {
|
|
358
|
+
res.writeHead(200, {
|
|
359
|
+
"Content-Type": MIME[ext] || "application/octet-stream",
|
|
360
|
+
});
|
|
276
361
|
res.end(readFileSync(resolved));
|
|
277
362
|
return;
|
|
278
363
|
}
|
|
279
364
|
|
|
280
365
|
res.writeHead(404);
|
|
281
|
-
res.end(
|
|
366
|
+
res.end("not found");
|
|
282
367
|
});
|
|
283
368
|
|
|
284
369
|
// ── File watching ─────────────────────────────────────────────────────────────
|
|
285
370
|
|
|
286
371
|
// Build a fingerprint of filenames + mtimes for change detection
|
|
287
372
|
async function dirFingerprint(dir) {
|
|
288
|
-
if (!existsSync(dir)) return
|
|
373
|
+
if (!existsSync(dir)) return "";
|
|
289
374
|
const files = await readdir(dir);
|
|
290
375
|
const parts = [];
|
|
291
376
|
for (const f of files) {
|
|
292
|
-
if (!f.endsWith(
|
|
377
|
+
if (!f.endsWith(".html") && !f.endsWith(".json")) continue;
|
|
293
378
|
try {
|
|
294
379
|
const s = await stat(join(dir, f));
|
|
295
380
|
parts.push(`${f}:${s.mtimeMs}`);
|
|
296
|
-
} catch {
|
|
381
|
+
} catch {
|
|
382
|
+
/* removed between readdir and stat */
|
|
383
|
+
}
|
|
297
384
|
}
|
|
298
|
-
return parts.sort().join(
|
|
385
|
+
return parts.sort().join("|");
|
|
299
386
|
}
|
|
300
387
|
|
|
301
388
|
async function claimsFingerprint() {
|
|
302
|
-
const claimsPath = join(ROOT,
|
|
389
|
+
const claimsPath = join(ROOT, "claims.json");
|
|
303
390
|
try {
|
|
304
391
|
const s = await stat(claimsPath);
|
|
305
392
|
return `claims:${s.mtimeMs}`;
|
|
306
|
-
} catch {
|
|
393
|
+
} catch {
|
|
394
|
+
return "";
|
|
395
|
+
}
|
|
307
396
|
}
|
|
308
397
|
|
|
309
|
-
let lastTemplatesFP =
|
|
310
|
-
let lastClaimsFP =
|
|
398
|
+
let lastTemplatesFP = "";
|
|
399
|
+
let lastClaimsFP = "";
|
|
311
400
|
|
|
312
401
|
const watchInterval = setInterval(async () => {
|
|
313
402
|
try {
|
|
@@ -320,20 +409,26 @@ const watchInterval = setInterval(async () => {
|
|
|
320
409
|
lastClaimsFP = cFP;
|
|
321
410
|
await refreshState();
|
|
322
411
|
}
|
|
323
|
-
} catch {
|
|
412
|
+
} catch {
|
|
413
|
+
/* ignore polling errors */
|
|
414
|
+
}
|
|
324
415
|
}, 2000);
|
|
325
416
|
|
|
326
417
|
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
327
418
|
const shutdown = (signal) => {
|
|
328
419
|
console.log(`\nbarn: ${signal} received, shutting down...`);
|
|
329
420
|
clearInterval(watchInterval);
|
|
330
|
-
for (const res of sseClients) {
|
|
421
|
+
for (const res of sseClients) {
|
|
422
|
+
try {
|
|
423
|
+
res.end();
|
|
424
|
+
} catch {}
|
|
425
|
+
}
|
|
331
426
|
sseClients.clear();
|
|
332
427
|
server.close(() => process.exit(0));
|
|
333
428
|
setTimeout(() => process.exit(1), 5000);
|
|
334
429
|
};
|
|
335
|
-
process.on(
|
|
336
|
-
process.on(
|
|
430
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
431
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
337
432
|
|
|
338
433
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
339
434
|
|
|
@@ -344,25 +439,27 @@ await refreshState();
|
|
|
344
439
|
claimsFingerprint(),
|
|
345
440
|
]);
|
|
346
441
|
|
|
347
|
-
server.on(
|
|
348
|
-
if (err.code ===
|
|
442
|
+
server.on("error", (err) => {
|
|
443
|
+
if (err.code === "EADDRINUSE") {
|
|
349
444
|
console.error(`barn: port ${PORT} already in use — try --port <other>`);
|
|
350
445
|
process.exit(1);
|
|
351
446
|
}
|
|
352
|
-
if (err.code ===
|
|
447
|
+
if (err.code === "EACCES") {
|
|
353
448
|
console.error(`barn: permission denied for port ${PORT}`);
|
|
354
449
|
process.exit(1);
|
|
355
450
|
}
|
|
356
451
|
throw err;
|
|
357
452
|
});
|
|
358
453
|
|
|
359
|
-
server.listen(PORT,
|
|
360
|
-
vlog(
|
|
454
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
455
|
+
vlog("listen", `port=${PORT}`, `root=${ROOT}`);
|
|
361
456
|
console.log(`barn: serving on http://localhost:${PORT}`);
|
|
362
457
|
console.log(` templates: ${state.templates.length} found`);
|
|
363
458
|
console.log(` sprints: ${state.sprints.length} detected`);
|
|
364
459
|
if (state.activeSprint) {
|
|
365
|
-
console.log(
|
|
460
|
+
console.log(
|
|
461
|
+
` active: ${state.activeSprint.name} (${state.activeSprint.phase})`,
|
|
462
|
+
);
|
|
366
463
|
}
|
|
367
464
|
console.log(` root: ${ROOT}`);
|
|
368
465
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grainulation/barn",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Template browser and sprint toolkit for the grainulation ecosystem",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "grainulation contributors",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"./detect-sprints": "./tools/detect-sprints.js",
|
|
29
29
|
"./generate-manifest": "./tools/generate-manifest.js",
|
|
30
30
|
"./build-pdf": "./tools/build-pdf.js",
|
|
31
|
-
"./tokens": "./public/grainulation-tokens.css"
|
|
31
|
+
"./tokens": "./public/grainulation-tokens.css",
|
|
32
|
+
"./status-icons": "./public/status-icons.svg"
|
|
32
33
|
},
|
|
33
34
|
"bin": {
|
|
34
35
|
"barn": "./bin/barn.js"
|
|
@@ -39,7 +40,10 @@
|
|
|
39
40
|
"public/",
|
|
40
41
|
"tools/",
|
|
41
42
|
"templates/",
|
|
42
|
-
"CHANGELOG.md"
|
|
43
|
+
"CHANGELOG.md",
|
|
44
|
+
"LICENSE",
|
|
45
|
+
"CODE_OF_CONDUCT.md",
|
|
46
|
+
"CONTRIBUTING.md"
|
|
43
47
|
],
|
|
44
48
|
"scripts": {
|
|
45
49
|
"start": "node bin/barn.js serve",
|
|
@@ -47,6 +51,6 @@
|
|
|
47
51
|
"test": "node test/basic.test.js"
|
|
48
52
|
},
|
|
49
53
|
"engines": {
|
|
50
|
-
"node": ">=
|
|
54
|
+
"node": ">=20"
|
|
51
55
|
}
|
|
52
56
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
grainulation-icons.svg — Triple-encoding icon sprite
|
|
3
|
+
WCAG 1.4.1 compliant: each status indicator has a distinctive shape
|
|
4
|
+
so meaning is never conveyed by color alone.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
<svg class="icon" aria-hidden="true">
|
|
8
|
+
<use href="grainulation-icons.svg#checkmark-circle" />
|
|
9
|
+
</svg>
|
|
10
|
+
<span>Success message</span>
|
|
11
|
+
|
|
12
|
+
Icons:
|
|
13
|
+
checkmark-circle — success (circle + checkmark)
|
|
14
|
+
x-circle — error (circle + X)
|
|
15
|
+
triangle-alert — warning (triangle + exclamation)
|
|
16
|
+
info-circle — info (circle + i)
|
|
17
|
+
-->
|
|
18
|
+
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
|
19
|
+
|
|
20
|
+
<!-- ✓ Success: checkmark inside circle -->
|
|
21
|
+
<symbol id="checkmark-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
22
|
+
<circle cx="12" cy="12" r="10" />
|
|
23
|
+
<path d="M9 12l2 2 4-4" />
|
|
24
|
+
</symbol>
|
|
25
|
+
|
|
26
|
+
<!-- ✗ Error: X inside circle -->
|
|
27
|
+
<symbol id="x-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
28
|
+
<circle cx="12" cy="12" r="10" />
|
|
29
|
+
<path d="M15 9l-6 6" />
|
|
30
|
+
<path d="M9 9l6 6" />
|
|
31
|
+
</symbol>
|
|
32
|
+
|
|
33
|
+
<!-- ⚠ Warning: exclamation inside triangle -->
|
|
34
|
+
<symbol id="triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
35
|
+
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
|
36
|
+
<line x1="12" y1="9" x2="12" y2="13" />
|
|
37
|
+
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
38
|
+
</symbol>
|
|
39
|
+
|
|
40
|
+
<!-- ℹ Info: i inside circle -->
|
|
41
|
+
<symbol id="info-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
42
|
+
<circle cx="12" cy="12" r="10" />
|
|
43
|
+
<line x1="12" y1="16" x2="12" y2="12" />
|
|
44
|
+
<line x1="12" y1="8" x2="12.01" y2="8" />
|
|
45
|
+
</symbol>
|
|
46
|
+
|
|
47
|
+
</svg>
|