@grainulation/silo 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 +103 -0
- package/README.md +67 -59
- package/bin/silo.js +212 -86
- package/lib/analytics.js +26 -11
- package/lib/confluence.js +343 -0
- package/lib/graph.js +414 -0
- package/lib/import-export.js +29 -24
- package/lib/index.js +15 -9
- package/lib/packs.js +60 -36
- package/lib/search.js +24 -16
- package/lib/serve-mcp.js +391 -95
- package/lib/server.js +205 -110
- package/lib/store.js +34 -18
- package/lib/templates.js +28 -17
- package/package.json +7 -3
- package/packs/adr.json +219 -0
- package/packs/api-design.json +67 -14
- package/packs/architecture-decision.json +152 -0
- package/packs/architecture.json +45 -9
- package/packs/ci-cd.json +51 -11
- package/packs/compliance.json +70 -14
- package/packs/data-engineering.json +57 -12
- package/packs/frontend.json +56 -12
- package/packs/hackathon-best-ai.json +179 -0
- package/packs/hackathon-business-impact.json +180 -0
- package/packs/hackathon-innovation.json +210 -0
- package/packs/hackathon-most-innovative.json +179 -0
- package/packs/hackathon-most-rigorous.json +179 -0
- package/packs/hackathon-sprint-boost.json +173 -0
- package/packs/incident-postmortem.json +219 -0
- package/packs/migration.json +45 -9
- package/packs/observability.json +57 -12
- package/packs/security.json +61 -13
- package/packs/team-process.json +64 -13
- package/packs/testing.json +20 -4
- package/packs/vendor-eval.json +219 -0
- package/packs/vendor-evaluation.json +148 -0
package/lib/server.js
CHANGED
|
@@ -9,26 +9,36 @@
|
|
|
9
9
|
* silo serve [--port 9095] [--root /path/to/repo]
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { createServer } from
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import {
|
|
14
|
+
readFileSync,
|
|
15
|
+
existsSync,
|
|
16
|
+
readdirSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
statSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { join, resolve, extname, dirname } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { createRequire } from "node:module";
|
|
17
23
|
|
|
18
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
25
|
const require = createRequire(import.meta.url);
|
|
20
26
|
|
|
21
27
|
// ── Crash handlers ──
|
|
22
|
-
process.on(
|
|
23
|
-
process.stderr.write(
|
|
28
|
+
process.on("uncaughtException", (err) => {
|
|
29
|
+
process.stderr.write(
|
|
30
|
+
`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`,
|
|
31
|
+
);
|
|
24
32
|
process.exit(1);
|
|
25
33
|
});
|
|
26
|
-
process.on(
|
|
27
|
-
process.stderr.write(
|
|
34
|
+
process.on("unhandledRejection", (reason) => {
|
|
35
|
+
process.stderr.write(
|
|
36
|
+
`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`,
|
|
37
|
+
);
|
|
28
38
|
});
|
|
29
39
|
|
|
30
|
-
const PUBLIC_DIR = join(__dirname,
|
|
31
|
-
const PACKS_DIR = join(__dirname,
|
|
40
|
+
const PUBLIC_DIR = join(__dirname, "..", "public");
|
|
41
|
+
const PACKS_DIR = join(__dirname, "..", "packs");
|
|
32
42
|
|
|
33
43
|
// ── CLI args ──────────────────────────────────────────────────────────────────
|
|
34
44
|
|
|
@@ -38,38 +48,67 @@ function arg(name, fallback) {
|
|
|
38
48
|
return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
const PORT = parseInt(arg(
|
|
42
|
-
const ROOT = resolve(arg(
|
|
43
|
-
const CORS_ORIGIN = arg(
|
|
51
|
+
const PORT = parseInt(arg("port", "9095"), 10);
|
|
52
|
+
const ROOT = resolve(arg("root", process.cwd()));
|
|
53
|
+
const CORS_ORIGIN = arg("cors", null);
|
|
44
54
|
|
|
45
55
|
// ── Verbose logging ──────────────────────────────────────────────────────────
|
|
46
56
|
|
|
47
|
-
const verbose =
|
|
57
|
+
const verbose =
|
|
58
|
+
process.argv.includes("--verbose") || process.argv.includes("-v");
|
|
48
59
|
function vlog(...a) {
|
|
49
60
|
if (!verbose) return;
|
|
50
61
|
const ts = new Date().toISOString();
|
|
51
|
-
process.stderr.write(`[${ts}] silo: ${a.join(
|
|
62
|
+
process.stderr.write(`[${ts}] silo: ${a.join(" ")}\n`);
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
// ── Routes manifest ──────────────────────────────────────────────────────────
|
|
55
66
|
|
|
56
67
|
const ROUTES = [
|
|
57
|
-
{ method:
|
|
58
|
-
{
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
{
|
|
64
|
-
|
|
68
|
+
{ method: "GET", path: "/health", description: "Health check endpoint" },
|
|
69
|
+
{
|
|
70
|
+
method: "GET",
|
|
71
|
+
path: "/events",
|
|
72
|
+
description: "SSE event stream for live updates",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
method: "GET",
|
|
76
|
+
path: "/api/packs",
|
|
77
|
+
description: "List all available knowledge packs",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
method: "GET",
|
|
81
|
+
path: "/api/packs/:name",
|
|
82
|
+
description: "Get pack details and claims by name",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
method: "POST",
|
|
86
|
+
path: "/api/import",
|
|
87
|
+
description: "Import a pack into a target claims file",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
method: "GET",
|
|
91
|
+
path: "/api/search",
|
|
92
|
+
description: "Search claims by ?q, ?type, ?evidence, ?limit",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
method: "POST",
|
|
96
|
+
path: "/api/refresh",
|
|
97
|
+
description: "Reload packs from disk",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
method: "GET",
|
|
101
|
+
path: "/api/docs",
|
|
102
|
+
description: "This API documentation page",
|
|
103
|
+
},
|
|
65
104
|
];
|
|
66
105
|
|
|
67
106
|
// ── Load existing CJS modules via createRequire ──────────────────────────────
|
|
68
107
|
|
|
69
|
-
const { Store } = require(
|
|
70
|
-
const { Search } = require(
|
|
71
|
-
const { Packs } = require(
|
|
72
|
-
const { ImportExport } = require(
|
|
108
|
+
const { Store } = require("./store.js");
|
|
109
|
+
const { Search } = require("./search.js");
|
|
110
|
+
const { Packs } = require("./packs.js");
|
|
111
|
+
const { ImportExport } = require("./import-export.js");
|
|
73
112
|
|
|
74
113
|
const store = new Store();
|
|
75
114
|
const search = new Search(store);
|
|
@@ -88,7 +127,11 @@ const sseClients = new Set();
|
|
|
88
127
|
function broadcast(event) {
|
|
89
128
|
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
90
129
|
for (const res of sseClients) {
|
|
91
|
-
try {
|
|
130
|
+
try {
|
|
131
|
+
res.write(data);
|
|
132
|
+
} catch {
|
|
133
|
+
sseClients.delete(res);
|
|
134
|
+
}
|
|
92
135
|
}
|
|
93
136
|
}
|
|
94
137
|
|
|
@@ -100,24 +143,24 @@ function loadPacks() {
|
|
|
100
143
|
|
|
101
144
|
function refreshState() {
|
|
102
145
|
state.packs = loadPacks();
|
|
103
|
-
broadcast({ type:
|
|
146
|
+
broadcast({ type: "state", data: state });
|
|
104
147
|
}
|
|
105
148
|
|
|
106
149
|
// ── MIME types ────────────────────────────────────────────────────────────────
|
|
107
150
|
|
|
108
151
|
const MIME = {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
152
|
+
".html": "text/html; charset=utf-8",
|
|
153
|
+
".css": "text/css; charset=utf-8",
|
|
154
|
+
".js": "application/javascript; charset=utf-8",
|
|
155
|
+
".json": "application/json; charset=utf-8",
|
|
156
|
+
".svg": "image/svg+xml",
|
|
157
|
+
".png": "image/png",
|
|
115
158
|
};
|
|
116
159
|
|
|
117
160
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
118
161
|
|
|
119
162
|
function jsonResponse(res, code, data) {
|
|
120
|
-
res.writeHead(code, {
|
|
163
|
+
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
|
|
121
164
|
res.end(JSON.stringify(data));
|
|
122
165
|
}
|
|
123
166
|
|
|
@@ -125,12 +168,23 @@ function readBody(req) {
|
|
|
125
168
|
return new Promise((resolve, reject) => {
|
|
126
169
|
const chunks = [];
|
|
127
170
|
let size = 0;
|
|
128
|
-
req.on(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
171
|
+
req.on("data", (c) => {
|
|
172
|
+
size += c.length;
|
|
173
|
+
if (size > 1048576) {
|
|
174
|
+
resolve(null);
|
|
175
|
+
req.destroy();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
chunks.push(c);
|
|
132
179
|
});
|
|
133
|
-
req.on(
|
|
180
|
+
req.on("end", () => {
|
|
181
|
+
try {
|
|
182
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString()));
|
|
183
|
+
} catch {
|
|
184
|
+
resolve(null);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
req.on("error", () => resolve(null));
|
|
134
188
|
});
|
|
135
189
|
}
|
|
136
190
|
|
|
@@ -141,71 +195,89 @@ const server = createServer(async (req, res) => {
|
|
|
141
195
|
|
|
142
196
|
// CORS (only when --cors is passed)
|
|
143
197
|
if (CORS_ORIGIN) {
|
|
144
|
-
res.setHeader(
|
|
145
|
-
res.setHeader(
|
|
146
|
-
res.setHeader(
|
|
198
|
+
res.setHeader("Access-Control-Allow-Origin", CORS_ORIGIN);
|
|
199
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
200
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
147
201
|
}
|
|
148
202
|
|
|
149
|
-
if (req.method ===
|
|
203
|
+
if (req.method === "OPTIONS" && CORS_ORIGIN) {
|
|
150
204
|
res.writeHead(204);
|
|
151
205
|
res.end();
|
|
152
206
|
return;
|
|
153
207
|
}
|
|
154
208
|
|
|
155
|
-
vlog(
|
|
209
|
+
vlog("request", req.method, url.pathname);
|
|
156
210
|
|
|
157
211
|
// ── API: docs ──
|
|
158
|
-
if (req.method ===
|
|
212
|
+
if (req.method === "GET" && url.pathname === "/api/docs") {
|
|
159
213
|
const html = `<!DOCTYPE html><html><head><title>silo API</title>
|
|
160
214
|
<style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
|
|
161
215
|
table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
|
|
162
216
|
th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
|
|
163
217
|
<body><h1>silo API</h1><p>${ROUTES.length} endpoints</p>
|
|
164
218
|
<table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
|
|
165
|
-
${ROUTES.map(r =>
|
|
219
|
+
${ROUTES.map((r) => "<tr><td><code>" + r.method + "</code></td><td><code>" + r.path + "</code></td><td>" + r.description + "</td></tr>").join("")}
|
|
166
220
|
</table></body></html>`;
|
|
167
|
-
res.writeHead(200, {
|
|
221
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
168
222
|
res.end(html);
|
|
169
223
|
return;
|
|
170
224
|
}
|
|
171
225
|
|
|
172
226
|
// ── Health check ──
|
|
173
|
-
if (req.method ===
|
|
174
|
-
jsonResponse(res, 200, {
|
|
227
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
228
|
+
jsonResponse(res, 200, {
|
|
229
|
+
status: "ok",
|
|
230
|
+
uptime: process.uptime(),
|
|
231
|
+
packs: state.packs.length,
|
|
232
|
+
});
|
|
175
233
|
return;
|
|
176
234
|
}
|
|
177
235
|
|
|
178
236
|
// ── SSE endpoint ──
|
|
179
|
-
if (req.method ===
|
|
237
|
+
if (req.method === "GET" && url.pathname === "/events") {
|
|
180
238
|
res.writeHead(200, {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
239
|
+
"Content-Type": "text/event-stream",
|
|
240
|
+
"Cache-Control": "no-cache",
|
|
241
|
+
Connection: "keep-alive",
|
|
184
242
|
});
|
|
185
|
-
res.write(`data: ${JSON.stringify({ type:
|
|
243
|
+
res.write(`data: ${JSON.stringify({ type: "state", data: state })}\n\n`);
|
|
186
244
|
const heartbeat = setInterval(() => {
|
|
187
|
-
try {
|
|
245
|
+
try {
|
|
246
|
+
res.write(": heartbeat\n\n");
|
|
247
|
+
} catch {
|
|
248
|
+
clearInterval(heartbeat);
|
|
249
|
+
}
|
|
188
250
|
}, 15000);
|
|
189
251
|
sseClients.add(res);
|
|
190
|
-
vlog(
|
|
191
|
-
req.on(
|
|
252
|
+
vlog("sse", `client connected (${sseClients.size} total)`);
|
|
253
|
+
req.on("close", () => {
|
|
254
|
+
clearInterval(heartbeat);
|
|
255
|
+
sseClients.delete(res);
|
|
256
|
+
vlog("sse", `client disconnected (${sseClients.size} total)`);
|
|
257
|
+
});
|
|
192
258
|
return;
|
|
193
259
|
}
|
|
194
260
|
|
|
195
261
|
// ── API: list packs ──
|
|
196
|
-
if (req.method ===
|
|
262
|
+
if (req.method === "GET" && url.pathname === "/api/packs") {
|
|
197
263
|
const packList = loadPacks();
|
|
198
264
|
jsonResponse(res, 200, { packs: packList });
|
|
199
265
|
return;
|
|
200
266
|
}
|
|
201
267
|
|
|
202
268
|
// ── API: get pack details ──
|
|
203
|
-
if (req.method ===
|
|
204
|
-
const name = decodeURIComponent(url.pathname.slice(
|
|
205
|
-
if (!name) {
|
|
269
|
+
if (req.method === "GET" && url.pathname.startsWith("/api/packs/")) {
|
|
270
|
+
const name = decodeURIComponent(url.pathname.slice("/api/packs/".length));
|
|
271
|
+
if (!name) {
|
|
272
|
+
jsonResponse(res, 400, { error: "missing pack name" });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
206
275
|
|
|
207
276
|
const pack = packs.get(name);
|
|
208
|
-
if (!pack) {
|
|
277
|
+
if (!pack) {
|
|
278
|
+
jsonResponse(res, 404, { error: `pack "${name}" not found` });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
209
281
|
|
|
210
282
|
jsonResponse(res, 200, {
|
|
211
283
|
id: name,
|
|
@@ -219,25 +291,27 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
219
291
|
}
|
|
220
292
|
|
|
221
293
|
// ── API: import pack ──
|
|
222
|
-
if (req.method ===
|
|
294
|
+
if (req.method === "POST" && url.pathname === "/api/import") {
|
|
223
295
|
try {
|
|
224
296
|
const body = await readBody(req);
|
|
225
297
|
const { pack: packName, targetDir } = body;
|
|
226
298
|
if (!packName || !targetDir) {
|
|
227
|
-
jsonResponse(res, 400, { error:
|
|
299
|
+
jsonResponse(res, 400, { error: "missing pack or targetDir" });
|
|
228
300
|
return;
|
|
229
301
|
}
|
|
230
302
|
|
|
231
303
|
const absTarget = resolve(targetDir);
|
|
232
304
|
if (!absTarget.startsWith(ROOT)) {
|
|
233
|
-
jsonResponse(res, 400, {
|
|
305
|
+
jsonResponse(res, 400, {
|
|
306
|
+
error: "target directory must be within the project root",
|
|
307
|
+
});
|
|
234
308
|
return;
|
|
235
309
|
}
|
|
236
310
|
|
|
237
|
-
const targetPath = resolve(absTarget,
|
|
311
|
+
const targetPath = resolve(absTarget, "claims.json");
|
|
238
312
|
// Ensure the target file exists
|
|
239
313
|
if (!existsSync(targetPath)) {
|
|
240
|
-
writeFileSync(targetPath,
|
|
314
|
+
writeFileSync(targetPath, "[]", "utf-8");
|
|
241
315
|
}
|
|
242
316
|
|
|
243
317
|
const result = io.pull(packName, targetPath);
|
|
@@ -255,11 +329,11 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
255
329
|
}
|
|
256
330
|
|
|
257
331
|
// ── API: search ──
|
|
258
|
-
if (req.method ===
|
|
259
|
-
const q = url.searchParams.get(
|
|
260
|
-
const type = url.searchParams.get(
|
|
261
|
-
const evidence = url.searchParams.get(
|
|
262
|
-
const limit = parseInt(url.searchParams.get(
|
|
332
|
+
if (req.method === "GET" && url.pathname === "/api/search") {
|
|
333
|
+
const q = url.searchParams.get("q") || "";
|
|
334
|
+
const type = url.searchParams.get("type") || undefined;
|
|
335
|
+
const evidence = url.searchParams.get("evidence") || undefined;
|
|
336
|
+
const limit = parseInt(url.searchParams.get("limit") || "20", 10);
|
|
263
337
|
|
|
264
338
|
if (!q) {
|
|
265
339
|
jsonResponse(res, 200, { query: q, results: [] });
|
|
@@ -276,26 +350,34 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
276
350
|
// Also search directly in packs/ directory
|
|
277
351
|
if (existsSync(PACKS_DIR)) {
|
|
278
352
|
for (const file of readdirSync(PACKS_DIR)) {
|
|
279
|
-
if (!file.endsWith(
|
|
353
|
+
if (!file.endsWith(".json")) continue;
|
|
280
354
|
try {
|
|
281
|
-
const data = JSON.parse(readFileSync(join(PACKS_DIR, file),
|
|
282
|
-
const packName = data.name || file.replace(
|
|
355
|
+
const data = JSON.parse(readFileSync(join(PACKS_DIR, file), "utf-8"));
|
|
356
|
+
const packName = data.name || file.replace(".json", "");
|
|
283
357
|
for (const claim of data.claims || []) {
|
|
284
358
|
if (type && claim.type !== type) continue;
|
|
285
359
|
if (evidence && claim.evidence !== evidence) continue;
|
|
286
360
|
const searchable = [
|
|
287
|
-
claim.content || claim.text ||
|
|
288
|
-
claim.type ||
|
|
289
|
-
claim.topic ||
|
|
290
|
-
(claim.tags || []).join(
|
|
291
|
-
]
|
|
292
|
-
|
|
293
|
-
|
|
361
|
+
claim.content || claim.text || "",
|
|
362
|
+
claim.type || "",
|
|
363
|
+
claim.topic || "",
|
|
364
|
+
(claim.tags || []).join(" "),
|
|
365
|
+
]
|
|
366
|
+
.join(" ")
|
|
367
|
+
.toLowerCase();
|
|
368
|
+
|
|
369
|
+
const tokens = q
|
|
370
|
+
.toLowerCase()
|
|
371
|
+
.split(/\s+/)
|
|
372
|
+
.filter((t) => t.length > 1);
|
|
294
373
|
let score = 0;
|
|
295
374
|
for (const token of tokens) {
|
|
296
375
|
if (searchable.includes(token)) {
|
|
297
376
|
score += 1;
|
|
298
|
-
if (
|
|
377
|
+
if (
|
|
378
|
+
searchable.includes(` ${token} `) ||
|
|
379
|
+
searchable.startsWith(`${token} `)
|
|
380
|
+
) {
|
|
299
381
|
score += 0.5;
|
|
300
382
|
}
|
|
301
383
|
}
|
|
@@ -304,7 +386,9 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
304
386
|
results.push({ claim, collection: `pack:${packName}`, score });
|
|
305
387
|
}
|
|
306
388
|
}
|
|
307
|
-
} catch {
|
|
389
|
+
} catch {
|
|
390
|
+
/* skip malformed */
|
|
391
|
+
}
|
|
308
392
|
}
|
|
309
393
|
}
|
|
310
394
|
|
|
@@ -326,54 +410,61 @@ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</c
|
|
|
326
410
|
}
|
|
327
411
|
|
|
328
412
|
// ── API: refresh ──
|
|
329
|
-
if (req.method ===
|
|
413
|
+
if (req.method === "POST" && url.pathname === "/api/refresh") {
|
|
330
414
|
refreshState();
|
|
331
415
|
jsonResponse(res, 200, state);
|
|
332
416
|
return;
|
|
333
417
|
}
|
|
334
418
|
|
|
335
419
|
// ── Static files ──
|
|
336
|
-
let filePath = url.pathname ===
|
|
337
|
-
const resolved = resolve(PUBLIC_DIR,
|
|
420
|
+
let filePath = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
421
|
+
const resolved = resolve(PUBLIC_DIR, "." + filePath);
|
|
338
422
|
if (!resolved.startsWith(PUBLIC_DIR)) {
|
|
339
423
|
res.writeHead(403);
|
|
340
|
-
res.end(
|
|
424
|
+
res.end("forbidden");
|
|
341
425
|
return;
|
|
342
426
|
}
|
|
343
427
|
|
|
344
428
|
if (existsSync(resolved) && statSync(resolved).isFile()) {
|
|
345
429
|
const ext = extname(resolved);
|
|
346
|
-
const mime = MIME[ext] ||
|
|
430
|
+
const mime = MIME[ext] || "application/octet-stream";
|
|
347
431
|
try {
|
|
348
432
|
const content = readFileSync(resolved);
|
|
349
|
-
res.writeHead(200, {
|
|
433
|
+
res.writeHead(200, { "Content-Type": mime });
|
|
350
434
|
res.end(content);
|
|
351
435
|
} catch {
|
|
352
436
|
res.writeHead(500);
|
|
353
|
-
res.end(
|
|
437
|
+
res.end("read error");
|
|
354
438
|
}
|
|
355
439
|
return;
|
|
356
440
|
}
|
|
357
441
|
|
|
358
442
|
// ── 404 ──
|
|
359
|
-
res.writeHead(404, {
|
|
360
|
-
res.end(
|
|
443
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
444
|
+
res.end("not found");
|
|
361
445
|
});
|
|
362
446
|
|
|
363
447
|
// ── File watching (fingerprint-based polling) ─────────────────────────────────
|
|
364
448
|
|
|
365
|
-
let lastFingerprint =
|
|
449
|
+
let lastFingerprint = "";
|
|
366
450
|
function computeFingerprint() {
|
|
367
451
|
const parts = [];
|
|
368
452
|
try {
|
|
369
453
|
if (existsSync(PACKS_DIR)) {
|
|
370
454
|
for (const file of readdirSync(PACKS_DIR)) {
|
|
371
|
-
if (!file.endsWith(
|
|
372
|
-
try {
|
|
455
|
+
if (!file.endsWith(".json")) continue;
|
|
456
|
+
try {
|
|
457
|
+
const s = statSync(join(PACKS_DIR, file));
|
|
458
|
+
parts.push(file + ":" + s.mtimeMs);
|
|
459
|
+
} catch {
|
|
460
|
+
/* skip */
|
|
461
|
+
}
|
|
373
462
|
}
|
|
374
463
|
}
|
|
375
|
-
} catch {
|
|
376
|
-
|
|
464
|
+
} catch {
|
|
465
|
+
/* skip */
|
|
466
|
+
}
|
|
467
|
+
return parts.join("|");
|
|
377
468
|
}
|
|
378
469
|
|
|
379
470
|
function startWatcher() {
|
|
@@ -383,7 +474,7 @@ function startWatcher() {
|
|
|
383
474
|
if (fp !== lastFingerprint) {
|
|
384
475
|
lastFingerprint = fp;
|
|
385
476
|
refreshState();
|
|
386
|
-
vlog(
|
|
477
|
+
vlog("watcher", "packs changed, state refreshed");
|
|
387
478
|
}
|
|
388
479
|
}, 5000);
|
|
389
480
|
}
|
|
@@ -391,23 +482,27 @@ function startWatcher() {
|
|
|
391
482
|
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
392
483
|
const shutdown = (signal) => {
|
|
393
484
|
console.log(`\nsilo: ${signal} received, shutting down...`);
|
|
394
|
-
for (const res of sseClients) {
|
|
485
|
+
for (const res of sseClients) {
|
|
486
|
+
try {
|
|
487
|
+
res.end();
|
|
488
|
+
} catch {}
|
|
489
|
+
}
|
|
395
490
|
sseClients.clear();
|
|
396
491
|
server.close(() => process.exit(0));
|
|
397
492
|
setTimeout(() => process.exit(1), 5000);
|
|
398
493
|
};
|
|
399
|
-
process.on(
|
|
400
|
-
process.on(
|
|
494
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
495
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
401
496
|
|
|
402
497
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
403
498
|
|
|
404
499
|
refreshState();
|
|
405
500
|
startWatcher();
|
|
406
501
|
|
|
407
|
-
server.on(
|
|
408
|
-
if (err.code ===
|
|
502
|
+
server.on("error", (err) => {
|
|
503
|
+
if (err.code === "EADDRINUSE") {
|
|
409
504
|
console.error(`silo: port ${PORT} is already in use. Try --port <other>.`);
|
|
410
|
-
} else if (err.code ===
|
|
505
|
+
} else if (err.code === "EACCES") {
|
|
411
506
|
console.error(`silo: port ${PORT} requires elevated privileges.`);
|
|
412
507
|
} else {
|
|
413
508
|
console.error(`silo: server error: ${err.message}`);
|
|
@@ -415,8 +510,8 @@ server.on('error', (err) => {
|
|
|
415
510
|
process.exit(1);
|
|
416
511
|
});
|
|
417
512
|
|
|
418
|
-
server.listen(PORT,
|
|
419
|
-
vlog(
|
|
513
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
514
|
+
vlog("listen", `port=${PORT}`, `root=${ROOT}`);
|
|
420
515
|
console.log(`silo: serving on http://localhost:${PORT}`);
|
|
421
516
|
console.log(` packs: ${state.packs.length} available`);
|
|
422
517
|
console.log(` root: ${ROOT}`);
|
package/lib/store.js
CHANGED
|
@@ -5,24 +5,29 @@
|
|
|
5
5
|
* No database, no dependencies — just the filesystem.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const fs = require(
|
|
9
|
-
const path = require(
|
|
10
|
-
const crypto = require(
|
|
8
|
+
const fs = require("node:fs");
|
|
9
|
+
const path = require("node:path");
|
|
10
|
+
const crypto = require("node:crypto");
|
|
11
11
|
|
|
12
|
-
const DEFAULT_SILO_DIR = path.join(require(
|
|
12
|
+
const DEFAULT_SILO_DIR = path.join(require("node:os").homedir(), ".silo");
|
|
13
13
|
|
|
14
14
|
class Store {
|
|
15
15
|
constructor(siloDir = DEFAULT_SILO_DIR) {
|
|
16
16
|
this.root = siloDir;
|
|
17
|
-
this.claimsDir = path.join(siloDir,
|
|
18
|
-
this.templatesDir = path.join(siloDir,
|
|
19
|
-
this.packsDir = path.join(siloDir,
|
|
20
|
-
this.indexPath = path.join(siloDir,
|
|
17
|
+
this.claimsDir = path.join(siloDir, "claims");
|
|
18
|
+
this.templatesDir = path.join(siloDir, "templates");
|
|
19
|
+
this.packsDir = path.join(siloDir, "packs");
|
|
20
|
+
this.indexPath = path.join(siloDir, "index.json");
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** Ensure the silo directory structure exists. */
|
|
24
24
|
init() {
|
|
25
|
-
for (const dir of [
|
|
25
|
+
for (const dir of [
|
|
26
|
+
this.root,
|
|
27
|
+
this.claimsDir,
|
|
28
|
+
this.templatesDir,
|
|
29
|
+
this.packsDir,
|
|
30
|
+
]) {
|
|
26
31
|
if (!fs.existsSync(dir)) {
|
|
27
32
|
fs.mkdirSync(dir, { recursive: true });
|
|
28
33
|
}
|
|
@@ -44,7 +49,7 @@ class Store {
|
|
|
44
49
|
const entry = {
|
|
45
50
|
id,
|
|
46
51
|
name,
|
|
47
|
-
type:
|
|
52
|
+
type: "claims",
|
|
48
53
|
claimCount: claims.length,
|
|
49
54
|
hash: this._hash(JSON.stringify(claims)),
|
|
50
55
|
storedAt: new Date().toISOString(),
|
|
@@ -77,12 +82,20 @@ class Store {
|
|
|
77
82
|
this.init();
|
|
78
83
|
const results = [];
|
|
79
84
|
const index = this._readJSON(this.indexPath);
|
|
80
|
-
for (const entry of
|
|
85
|
+
for (const entry of index.collections || []) {
|
|
81
86
|
const data = this.getClaims(entry.id);
|
|
82
87
|
if (!data) {
|
|
83
|
-
results.push({
|
|
88
|
+
results.push({
|
|
89
|
+
id: entry.id,
|
|
90
|
+
ok: false,
|
|
91
|
+
warning: "Collection file missing",
|
|
92
|
+
});
|
|
84
93
|
} else {
|
|
85
|
-
results.push({
|
|
94
|
+
results.push({
|
|
95
|
+
id: entry.id,
|
|
96
|
+
ok: !data._integrityWarning,
|
|
97
|
+
warning: data._integrityWarning || null,
|
|
98
|
+
});
|
|
86
99
|
}
|
|
87
100
|
}
|
|
88
101
|
return results;
|
|
@@ -111,21 +124,24 @@ class Store {
|
|
|
111
124
|
// --- Internal helpers ---
|
|
112
125
|
|
|
113
126
|
_readJSON(filePath) {
|
|
114
|
-
return JSON.parse(fs.readFileSync(filePath,
|
|
127
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
115
128
|
}
|
|
116
129
|
|
|
117
130
|
_writeJSON(filePath, data) {
|
|
118
|
-
const tmp = filePath +
|
|
119
|
-
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) +
|
|
131
|
+
const tmp = filePath + ".tmp." + process.pid;
|
|
132
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
120
133
|
fs.renameSync(tmp, filePath);
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
_hash(str) {
|
|
124
|
-
return crypto.createHash(
|
|
137
|
+
return crypto.createHash("sha256").update(str).digest("hex");
|
|
125
138
|
}
|
|
126
139
|
|
|
127
140
|
_slugify(str) {
|
|
128
|
-
return str
|
|
141
|
+
return str
|
|
142
|
+
.toLowerCase()
|
|
143
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
144
|
+
.replace(/^-|-$/g, "");
|
|
129
145
|
}
|
|
130
146
|
|
|
131
147
|
_addToIndex(entry) {
|