contentrain 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +168 -0
  3. package/dist/context-An3xrvcP.mjs +50 -0
  4. package/dist/diff-otz0kGqT.mjs +116 -0
  5. package/dist/doctor-DyKjAIKH.mjs +187 -0
  6. package/dist/generate-DDEDRLdy.mjs +76 -0
  7. package/dist/index.mjs +21 -0
  8. package/dist/init-D5L_7bKE.mjs +415 -0
  9. package/dist/serve-CaCZMeHF.mjs +111 -0
  10. package/dist/serve-ui/assets/AgentPromptGroup.vue_vue_type_script_setup_true_lang-Dy-5bpEY.js +16 -0
  11. package/dist/serve-ui/assets/AppLayout-CJFq2ApF.js +16 -0
  12. package/dist/serve-ui/assets/BranchDetailPage-DzSu3JlE.js +17 -0
  13. package/dist/serve-ui/assets/BranchesPage-ZSG-l44J.js +6 -0
  14. package/dist/serve-ui/assets/CollapsibleTrigger.vue_vue_type_script_setup_true_lang-C4oejwcu.js +1 -0
  15. package/dist/serve-ui/assets/Collection-zzx9pNd2.js +6 -0
  16. package/dist/serve-ui/assets/ConfigProvider-BGjDLViI.js +1 -0
  17. package/dist/serve-ui/assets/ContentListPage-DTFaQB8N.js +51 -0
  18. package/dist/serve-ui/assets/ContentListPage-vXxAeSg8.css +1 -0
  19. package/dist/serve-ui/assets/ContentPage-4sQsAlY2.js +1 -0
  20. package/dist/serve-ui/assets/DashboardPage-CZDlnvAE.js +21 -0
  21. package/dist/serve-ui/assets/DialogTitle.vue_vue_type_script_setup_true_lang-CBu_dmA6.js +1 -0
  22. package/dist/serve-ui/assets/Input.vue_vue_type_script_setup_true_lang-otPltlZG.js +6 -0
  23. package/dist/serve-ui/assets/ModelDetailPage-BMquKCtH.js +49 -0
  24. package/dist/serve-ui/assets/ModelsPage-C_K-aWXf.js +1 -0
  25. package/dist/serve-ui/assets/NormalizePage-CHkT6yLV.js +11 -0
  26. package/dist/serve-ui/assets/PageHeader.vue_vue_type_script_setup_true_lang-C7aWxNSw.js +11 -0
  27. package/dist/serve-ui/assets/Presence-cbB6AqkQ.js +3 -0
  28. package/dist/serve-ui/assets/Separator.vue_vue_type_script_setup_true_lang-fePFl24S.js +1 -0
  29. package/dist/serve-ui/assets/StudioHint.vue_vue_type_script_setup_true_lang-B45TG2r1.js +6 -0
  30. package/dist/serve-ui/assets/TrustBadge.vue_vue_type_script_setup_true_lang-CgoQGS6k.js +11 -0
  31. package/dist/serve-ui/assets/ValidatePage-BW8DO6WC.js +21 -0
  32. package/dist/serve-ui/assets/arrow-down-DqOfGL7C.js +6 -0
  33. package/dist/serve-ui/assets/arrow-left-BRvgeveW.js +6 -0
  34. package/dist/serve-ui/assets/book-marked-D8StxRZP.js +6 -0
  35. package/dist/serve-ui/assets/book-open-BrGAe3tk.js +6 -0
  36. package/dist/serve-ui/assets/box-7olVZr5D.js +6 -0
  37. package/dist/serve-ui/assets/chevron-down-B9YdCtsj.js +6 -0
  38. package/dist/serve-ui/assets/chevron-right-DFKsmZVd.js +6 -0
  39. package/dist/serve-ui/assets/content-BQE3NEm5.js +1 -0
  40. package/dist/serve-ui/assets/database-BGEFn4Oq.js +6 -0
  41. package/dist/serve-ui/assets/file-code-BGiS4dun.js +6 -0
  42. package/dist/serve-ui/assets/file-text-BDHXQ95W.js +6 -0
  43. package/dist/serve-ui/assets/getActiveElement-FUKBWQwh.js +1 -0
  44. package/dist/serve-ui/assets/git-branch-DdE7bk9-.js +6 -0
  45. package/dist/serve-ui/assets/git-merge-iStuJS69.js +6 -0
  46. package/dist/serve-ui/assets/hash-B-ibLpM1.js +6 -0
  47. package/dist/serve-ui/assets/index-C-RjMzFm.css +1 -0
  48. package/dist/serve-ui/assets/index-CoUshYEB.js +80 -0
  49. package/dist/serve-ui/assets/index-WHgvdYOs.js +1 -0
  50. package/dist/serve-ui/assets/languages-BTcd2Rl1.js +6 -0
  51. package/dist/serve-ui/assets/model-empty-state-B6AM4bil.js +1 -0
  52. package/dist/serve-ui/assets/plus-D8Nd3YnG.js +11 -0
  53. package/dist/serve-ui/assets/project-B1qSm0Fz.js +1 -0
  54. package/dist/serve-ui/assets/scan-search-DGPL_VJu.js +6 -0
  55. package/dist/serve-ui/assets/settings-DL-0RaxD.js +6 -0
  56. package/dist/serve-ui/assets/shield-check-CUL7KkSA.js +6 -0
  57. package/dist/serve-ui/assets/trash-2-83QhL6-T.js +11 -0
  58. package/dist/serve-ui/assets/useDirection-Cd3C7VdT.js +1 -0
  59. package/dist/serve-ui/assets/useFormatters-DgWFpsAv.js +1 -0
  60. package/dist/serve-ui/assets/useId-C0uR6mdj.js +1 -0
  61. package/dist/serve-ui/assets/useNonce-DTGvzOJz.js +1 -0
  62. package/dist/serve-ui/assets/useWatch-COpVIQvm.js +1 -0
  63. package/dist/serve-ui/favicon.svg +19 -0
  64. package/dist/serve-ui/for-developers-1.svg +65 -0
  65. package/dist/serve-ui/icon-color.svg +18 -0
  66. package/dist/serve-ui/index.html +17 -0
  67. package/dist/serve-ui/logo/color-icon-black-text.svg +65 -0
  68. package/dist/serve-ui/logo/color-icon-white-text.svg +61 -0
  69. package/dist/serve-ui/merge-1.svg +131 -0
  70. package/dist/serve-ui/merge-2.svg +161 -0
  71. package/dist/serve-ui/model-empty-state.svg +12 -0
  72. package/dist/serve-ui/select-data-type.svg +23 -0
  73. package/dist/server-DW0KXB0T.mjs +607 -0
  74. package/dist/status-kF5miVty.mjs +122 -0
  75. package/dist/ui-B5mXontH.mjs +33 -0
  76. package/dist/validate-DnVamp3p.mjs +155 -0
  77. package/package.json +66 -0
@@ -0,0 +1,607 @@
1
+ import { simpleGit } from "simple-git";
2
+ import { join } from "node:path";
3
+ import { readFile, unlink, writeFile } from "node:fs/promises";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { createServer } from "@contentrain/mcp/server";
6
+ import { createApp, createError, createRouter, defineEventHandler, getQuery, getRouterParam, readBody, toNodeListener } from "h3";
7
+ import { WebSocketServer } from "ws";
8
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
9
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10
+ import { watch as watch$1 } from "chokidar";
11
+ //#region src/serve/server.ts
12
+ function apiError(error) {
13
+ if (error && typeof error === "object" && "statusCode" in error) {
14
+ const e = error;
15
+ return {
16
+ statusCode: e.statusCode,
17
+ error: e.message ?? "Unknown error"
18
+ };
19
+ }
20
+ return {
21
+ statusCode: 500,
22
+ error: error instanceof Error ? error.message : String(error)
23
+ };
24
+ }
25
+ async function createServeApp(options) {
26
+ const { projectRoot, uiDir } = options;
27
+ const crDir = join(projectRoot, ".contentrain");
28
+ function getDefaultBranch() {
29
+ try {
30
+ const raw = readFileSync(join(crDir, "config.json"), "utf-8");
31
+ const cfg = JSON.parse(raw);
32
+ if (cfg?.repository?.default_branch) return cfg.repository.default_branch;
33
+ } catch {}
34
+ return "main";
35
+ }
36
+ const mcpServer = createServer(projectRoot);
37
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
38
+ const mcpClient = new Client({
39
+ name: "serve-client",
40
+ version: "1.0.0"
41
+ });
42
+ await Promise.all([mcpClient.connect(clientTransport), mcpServer.connect(serverTransport)]);
43
+ async function callTool(name, args = {}) {
44
+ const result = await mcpClient.callTool({
45
+ name,
46
+ arguments: args
47
+ });
48
+ const textContent = result.content.find((c) => c.type === "text");
49
+ if (!textContent || !("text" in textContent)) throw createError({
50
+ statusCode: 500,
51
+ message: `Tool ${name} returned no text content`
52
+ });
53
+ const parsed = JSON.parse(textContent.text);
54
+ if (result.isError) throw createError({
55
+ statusCode: 422,
56
+ message: typeof parsed === "object" && parsed?.error ? parsed.error : textContent.text
57
+ });
58
+ return parsed;
59
+ }
60
+ const app = createApp({ onError: (error, event) => {
61
+ if (event.path.startsWith("/api/")) {
62
+ const { statusCode, error: message } = apiError(error);
63
+ event.node.res.statusCode = statusCode;
64
+ event.node.res.setHeader("Content-Type", "application/json");
65
+ if (!event.node.res.writableEnded) event.node.res.end(JSON.stringify({
66
+ statusCode,
67
+ error: message
68
+ }));
69
+ }
70
+ } });
71
+ const router = createRouter();
72
+ const wss = new WebSocketServer({ noServer: true });
73
+ const wsClients = /* @__PURE__ */ new Set();
74
+ function broadcast(event) {
75
+ const data = JSON.stringify(event);
76
+ for (const client of wsClients) if (client.readyState === 1) client.send(data);
77
+ }
78
+ wss.on("connection", (ws) => {
79
+ wsClients.add(ws);
80
+ ws.send(JSON.stringify({ type: "connected" }));
81
+ ws.on("close", () => {
82
+ wsClients.delete(ws);
83
+ });
84
+ ws.on("error", () => {
85
+ wsClients.delete(ws);
86
+ });
87
+ });
88
+ function handleUpgrade(req, socket, head) {
89
+ if (req.url !== "/ws") {
90
+ socket.destroy();
91
+ return;
92
+ }
93
+ wss.handleUpgrade(req, socket, head, (ws) => {
94
+ wss.emit("connection", ws, req);
95
+ });
96
+ }
97
+ if (existsSync(crDir)) {
98
+ let debounceTimer = null;
99
+ const pendingEvents = [];
100
+ const watcher = watch$1(crDir, {
101
+ ignoreInitial: true,
102
+ ignored: ["**/node_modules/**", "**/.git/**"],
103
+ depth: 4
104
+ });
105
+ function flush() {
106
+ if (pendingEvents.length === 0) return;
107
+ const events = [...pendingEvents];
108
+ pendingEvents.length = 0;
109
+ for (const event of events) broadcast(event);
110
+ }
111
+ watcher.on("all", (_eventType, filePath) => {
112
+ const rel = filePath.replace(crDir, "").replace(/^\//, "");
113
+ let event;
114
+ if (rel === "config.json") event = { type: "config:changed" };
115
+ else if (rel === "context.json") {
116
+ let context = null;
117
+ try {
118
+ const raw = readFileSync(join(crDir, "context.json"), "utf-8");
119
+ context = JSON.parse(raw);
120
+ } catch {}
121
+ event = {
122
+ type: "context:changed",
123
+ context
124
+ };
125
+ } else if (rel === "normalize-plan.json") event = { type: "normalize:plan-updated" };
126
+ else if (rel.startsWith("models/")) event = {
127
+ type: "model:changed",
128
+ modelId: rel.replace("models/", "").replace(".json", "")
129
+ };
130
+ else if (rel.startsWith("content/")) {
131
+ const parts = rel.replace("content/", "").split("/");
132
+ event = {
133
+ type: "content:changed",
134
+ modelId: parts[1],
135
+ locale: parts[2]?.replace(".json", "")
136
+ };
137
+ } else return;
138
+ pendingEvents.push(event);
139
+ if (debounceTimer) clearTimeout(debounceTimer);
140
+ debounceTimer = setTimeout(flush, 300);
141
+ });
142
+ }
143
+ router.add("/api/status", defineEventHandler(async () => {
144
+ return await callTool("contentrain_status");
145
+ }));
146
+ router.add("/api/describe/:modelId", defineEventHandler(async (event) => {
147
+ return await callTool("contentrain_describe", {
148
+ model: getRouterParam(event, "modelId"),
149
+ include_sample: true
150
+ });
151
+ }));
152
+ router.add("/api/content/:modelId", defineEventHandler(async (event) => {
153
+ const modelId = getRouterParam(event, "modelId");
154
+ const query = getQuery(event);
155
+ return await callTool("contentrain_content_list", {
156
+ model: modelId,
157
+ locale: query["locale"],
158
+ limit: query["limit"] ? Number(query["limit"]) : void 0,
159
+ offset: query["offset"] ? Number(query["offset"]) : void 0
160
+ });
161
+ }));
162
+ router.add("/api/content/:modelId/:entryId", defineEventHandler(async (event) => {
163
+ const modelId = getRouterParam(event, "modelId");
164
+ const entryId = getRouterParam(event, "entryId");
165
+ return await callTool("contentrain_content_list", {
166
+ model: modelId,
167
+ locale: getQuery(event)["locale"],
168
+ filter: { id: entryId },
169
+ limit: 1
170
+ });
171
+ }));
172
+ router.add("/api/content/:modelId/:entryId/fix", defineEventHandler(async (event) => {
173
+ if (event.method !== "POST") throw createError({
174
+ statusCode: 405,
175
+ message: "Method not allowed"
176
+ });
177
+ const modelId = getRouterParam(event, "modelId");
178
+ const entryId = getRouterParam(event, "entryId");
179
+ const body = await readBody(event);
180
+ const result = await callTool("contentrain_content_save", {
181
+ model: modelId,
182
+ entries: [{
183
+ id: entryId,
184
+ locale: body.locale,
185
+ data: body.data
186
+ }]
187
+ });
188
+ broadcast({ type: "validation:updated" });
189
+ const git = result?.git;
190
+ if (git?.branch && git?.action === "pending-review") broadcast({
191
+ type: "branch:created",
192
+ branch: git.branch
193
+ });
194
+ return result;
195
+ }));
196
+ router.add("/api/validate", defineEventHandler(async (event) => {
197
+ const query = getQuery(event);
198
+ return await callTool("contentrain_validate", {
199
+ model: query["model"],
200
+ fix: query["fix"] === "true"
201
+ });
202
+ }));
203
+ router.add("/api/branches", defineEventHandler(async () => {
204
+ const branches = await simpleGit(projectRoot).branchLocal();
205
+ const crBranches = branches.all.filter((b) => b.startsWith("contentrain/")).map((b) => ({
206
+ name: b,
207
+ current: b === branches.current
208
+ }));
209
+ return {
210
+ branches: crBranches,
211
+ total: crBranches.length
212
+ };
213
+ }));
214
+ router.add("/api/branches/diff", defineEventHandler(async (event) => {
215
+ const branchName = getQuery(event)["name"];
216
+ if (!branchName?.startsWith("contentrain/")) throw createError({
217
+ statusCode: 400,
218
+ message: "Invalid branch name"
219
+ });
220
+ const git = simpleGit(projectRoot);
221
+ const defaultBranch = getDefaultBranch();
222
+ return {
223
+ branch: branchName,
224
+ base: defaultBranch,
225
+ stat: await git.diff([`${defaultBranch}...${branchName}`, "--stat"]),
226
+ diff: await git.diff([`${defaultBranch}...${branchName}`])
227
+ };
228
+ }));
229
+ router.add("/api/branches/approve", defineEventHandler(async (event) => {
230
+ if (event.method !== "POST") throw createError({
231
+ statusCode: 405,
232
+ message: "Method not allowed"
233
+ });
234
+ const branchName = (await readBody(event)).branch;
235
+ if (!branchName?.startsWith("contentrain/")) throw createError({
236
+ statusCode: 400,
237
+ message: "Invalid branch name"
238
+ });
239
+ const git = simpleGit(projectRoot);
240
+ const baseBranch = getDefaultBranch();
241
+ await git.checkout(baseBranch);
242
+ await git.merge([
243
+ branchName,
244
+ "--no-edit",
245
+ "-X",
246
+ "theirs"
247
+ ]);
248
+ await git.deleteLocalBranch(branchName, true);
249
+ broadcast({
250
+ type: "branch:merged",
251
+ branch: branchName
252
+ });
253
+ return {
254
+ status: "merged",
255
+ branch: branchName,
256
+ into: baseBranch
257
+ };
258
+ }));
259
+ router.add("/api/branches/reject", defineEventHandler(async (event) => {
260
+ if (event.method !== "POST") throw createError({
261
+ statusCode: 405,
262
+ message: "Method not allowed"
263
+ });
264
+ const branchName = (await readBody(event)).branch;
265
+ if (!branchName?.startsWith("contentrain/")) throw createError({
266
+ statusCode: 400,
267
+ message: "Invalid branch name"
268
+ });
269
+ await simpleGit(projectRoot).deleteLocalBranch(branchName, true);
270
+ return {
271
+ status: "deleted",
272
+ branch: branchName
273
+ };
274
+ }));
275
+ router.add("/api/history", defineEventHandler(async (event) => {
276
+ const query = getQuery(event);
277
+ const limit = Number(query["limit"]) || 50;
278
+ const entries = (await simpleGit(projectRoot).log({
279
+ maxCount: limit * 2,
280
+ format: {
281
+ hash: "%h",
282
+ fullHash: "%H",
283
+ message: "%s",
284
+ author: "%an",
285
+ email: "%ae",
286
+ date: "%aI",
287
+ relativeDate: "%ar"
288
+ }
289
+ })).all.filter((c) => {
290
+ const msg = c.message ?? "";
291
+ return msg.startsWith("[contentrain]") || msg.startsWith("Merge branch 'contentrain/") || msg.includes("contentrain");
292
+ }).slice(0, limit).map((c) => {
293
+ const msg = c.message ?? "";
294
+ let type = "other";
295
+ let target = "";
296
+ if (msg.startsWith("[contentrain] created:")) {
297
+ type = "model_create";
298
+ target = msg.replace("[contentrain] created: ", "");
299
+ } else if (msg.startsWith("[contentrain] updated:")) {
300
+ type = "model_update";
301
+ target = msg.replace("[contentrain] updated: ", "");
302
+ } else if (msg.startsWith("[contentrain] content:")) {
303
+ type = "content_save";
304
+ target = msg.replace("[contentrain] content: ", "");
305
+ } else if (msg.startsWith("[contentrain] deleted:")) {
306
+ type = "delete";
307
+ target = msg.replace("[contentrain] deleted: ", "");
308
+ } else if (msg.startsWith("[contentrain] update context")) type = "context_update";
309
+ else if (msg.startsWith("Merge branch 'contentrain/")) {
310
+ type = "merge";
311
+ target = msg.replace("Merge branch '", "").replace("'", "");
312
+ } else if (msg.startsWith("[contentrain]")) {
313
+ type = "operation";
314
+ target = msg.replace("[contentrain] ", "");
315
+ }
316
+ return {
317
+ hash: c.hash,
318
+ message: msg,
319
+ type,
320
+ target,
321
+ author: c.author,
322
+ date: c.date,
323
+ relativeDate: c.relativeDate
324
+ };
325
+ });
326
+ return {
327
+ entries,
328
+ total: entries.length
329
+ };
330
+ }));
331
+ router.add("/api/context", defineEventHandler(async () => {
332
+ const contextPath = join(crDir, "context.json");
333
+ if (!existsSync(contextPath)) return {
334
+ lastOperation: null,
335
+ stats: null
336
+ };
337
+ const raw = await readFile(contextPath, "utf-8");
338
+ return JSON.parse(raw);
339
+ }));
340
+ router.add("/api/tree", defineEventHandler(async (event) => {
341
+ const extensions = (getQuery(event)["ext"] ?? ".vue,.tsx,.jsx,.ts,.js,.astro,.svelte").split(",");
342
+ const { readdirSync } = await import("node:fs");
343
+ const ignoreDirs = new Set([
344
+ "node_modules",
345
+ ".git",
346
+ "dist",
347
+ ".tmp",
348
+ ".agents",
349
+ ".contentrain",
350
+ ".cache",
351
+ ".claude",
352
+ ".histoire",
353
+ "serve-ui"
354
+ ]);
355
+ function buildTree(dirPath, relativePath, depth) {
356
+ if (depth > 5) return null;
357
+ const name = relativePath.split("/").pop() ?? relativePath;
358
+ if (ignoreDirs.has(name)) return null;
359
+ try {
360
+ const entries = readdirSync(dirPath, { withFileTypes: true });
361
+ const children = [];
362
+ let fileCount = 0;
363
+ for (const entry of entries) {
364
+ if (entry.name.startsWith(".") && depth > 0) continue;
365
+ const childRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
366
+ const childFull = join(dirPath, entry.name);
367
+ if (entry.isDirectory()) {
368
+ const sub = buildTree(childFull, childRelative, depth + 1);
369
+ if (sub && (sub.fileCount ?? 0) > 0) children.push(sub);
370
+ } else if (entry.isFile()) {
371
+ const ext = "." + entry.name.split(".").pop();
372
+ if (extensions.includes(ext)) {
373
+ children.push({
374
+ name: entry.name,
375
+ path: childRelative,
376
+ type: "file"
377
+ });
378
+ fileCount++;
379
+ }
380
+ }
381
+ }
382
+ for (const child of children) if (child.type === "dir") fileCount += child.fileCount ?? 0;
383
+ if (fileCount === 0) return null;
384
+ return {
385
+ name,
386
+ path: relativePath || ".",
387
+ type: "dir",
388
+ children: children.toSorted((a, b) => {
389
+ if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
390
+ return a.name.localeCompare(b.name);
391
+ }),
392
+ fileCount
393
+ };
394
+ } catch {
395
+ return null;
396
+ }
397
+ }
398
+ return { tree: buildTree(projectRoot, "", 0)?.children ?? [] };
399
+ }));
400
+ router.add("/api/normalize/scan", defineEventHandler(async (event) => {
401
+ const query = getQuery(event);
402
+ const mode = query["mode"] ?? "summary";
403
+ const limit = Number(query["limit"]) || 30;
404
+ const offset = Number(query["offset"]) || 0;
405
+ const paths = query["paths"] ? query["paths"].split(",") : void 0;
406
+ return await callTool("contentrain_scan", {
407
+ mode,
408
+ limit,
409
+ offset,
410
+ ...paths ? { paths } : {}
411
+ });
412
+ }));
413
+ router.add("/api/normalize/results", defineEventHandler(async () => {
414
+ let lastNormalize = null;
415
+ try {
416
+ const raw = readFileSync(join(crDir, "context.json"), "utf-8");
417
+ const op = JSON.parse(raw)?.lastOperation;
418
+ if (op?.tool === "contentrain_scan" || op?.tool === "contentrain_apply") lastNormalize = op;
419
+ } catch {}
420
+ const branches = (await simpleGit(projectRoot).branchLocal()).all.filter((b) => b.startsWith("contentrain/normalize/")).map((b) => ({ name: b }));
421
+ return {
422
+ lastOperation: lastNormalize,
423
+ pendingBranches: branches
424
+ };
425
+ }));
426
+ router.add("/api/normalize/plan", defineEventHandler(async () => {
427
+ const planPath = join(crDir, "normalize-plan.json");
428
+ if (!existsSync(planPath)) return { plan: null };
429
+ try {
430
+ const raw = await readFile(planPath, "utf-8");
431
+ return { plan: JSON.parse(raw) };
432
+ } catch {
433
+ return { plan: null };
434
+ }
435
+ }));
436
+ router.add("/api/normalize/plan/reject", defineEventHandler(async (event) => {
437
+ if (event.method !== "DELETE" && event.method !== "POST") throw createError({
438
+ statusCode: 405,
439
+ message: "Method not allowed"
440
+ });
441
+ const planPath = join(crDir, "normalize-plan.json");
442
+ if (existsSync(planPath)) await unlink(planPath);
443
+ broadcast({ type: "normalize:plan-updated" });
444
+ return { status: "rejected" };
445
+ }));
446
+ router.add("/api/normalize/plan/approve", defineEventHandler(async (event) => {
447
+ if (event.method !== "POST") throw createError({
448
+ statusCode: 405,
449
+ message: "Method not allowed"
450
+ });
451
+ const planPath = join(crDir, "normalize-plan.json");
452
+ if (!existsSync(planPath)) throw createError({
453
+ statusCode: 404,
454
+ message: "No normalize plan found"
455
+ });
456
+ const raw = await readFile(planPath, "utf-8");
457
+ const plan = JSON.parse(raw);
458
+ const selectedModels = (await readBody(event))?.models;
459
+ plan.status = "approved";
460
+ plan.approved_at = (/* @__PURE__ */ new Date()).toISOString();
461
+ if (selectedModels) plan.approved_models = selectedModels;
462
+ await writeFile(planPath, JSON.stringify(plan, null, 2) + "\n", "utf-8");
463
+ const modelIds = selectedModels ?? plan.models?.map((m) => m.id) ?? [];
464
+ const modelMap = new Map((plan.models ?? []).map((m) => [m.id, m]));
465
+ const groupedByModel = /* @__PURE__ */ new Map();
466
+ for (const ext of plan.extractions ?? []) {
467
+ if (!modelIds.includes(ext.model)) continue;
468
+ const entries = groupedByModel.get(ext.model) ?? [];
469
+ entries.push({
470
+ locale: ext.locale,
471
+ data: { [ext.field]: ext.value },
472
+ source: {
473
+ file: ext.file,
474
+ line: ext.line,
475
+ value: ext.value
476
+ }
477
+ });
478
+ groupedByModel.set(ext.model, entries);
479
+ }
480
+ const applyExtractions = [];
481
+ for (const [modelId, entries] of groupedByModel) {
482
+ const model = modelMap.get(modelId);
483
+ applyExtractions.push({
484
+ model: modelId,
485
+ kind: model?.kind ?? "collection",
486
+ domain: model?.domain ?? "default",
487
+ i18n: model?.i18n,
488
+ fields: model?.fields,
489
+ entries
490
+ });
491
+ }
492
+ const result = await callTool("contentrain_apply", {
493
+ mode: "extract",
494
+ dry_run: false,
495
+ extractions: applyExtractions
496
+ });
497
+ if (existsSync(planPath)) await unlink(planPath);
498
+ broadcast({ type: "normalize:plan-updated" });
499
+ return result;
500
+ }));
501
+ router.add("/api/normalize/apply", defineEventHandler(async (event) => {
502
+ if (event.method !== "POST") throw createError({
503
+ statusCode: 405,
504
+ message: "Method not allowed"
505
+ });
506
+ return await callTool("contentrain_apply", await readBody(event));
507
+ }));
508
+ router.add("/api/normalize/approve", defineEventHandler(async (event) => {
509
+ if (event.method !== "POST") throw createError({
510
+ statusCode: 405,
511
+ message: "Method not allowed"
512
+ });
513
+ const branchName = (await readBody(event)).branch;
514
+ if (!branchName?.startsWith("contentrain/")) throw createError({
515
+ statusCode: 400,
516
+ message: "Invalid branch name"
517
+ });
518
+ const git = simpleGit(projectRoot);
519
+ const currentBranch = (await git.branchLocal()).current;
520
+ await git.merge([branchName]);
521
+ await git.deleteLocalBranch(branchName, true);
522
+ broadcast({
523
+ type: "branch:merged",
524
+ branch: branchName
525
+ });
526
+ return {
527
+ status: "merged",
528
+ branch: branchName,
529
+ into: currentBranch
530
+ };
531
+ }));
532
+ router.add("/api/normalize/sources", defineEventHandler(async () => {
533
+ const sourcesPath = join(crDir, "normalize-sources.json");
534
+ if (!existsSync(sourcesPath)) return { sources: null };
535
+ try {
536
+ const raw = await readFile(sourcesPath, "utf-8");
537
+ return { sources: JSON.parse(raw) };
538
+ } catch {
539
+ return { sources: null };
540
+ }
541
+ }));
542
+ router.add("/api/normalize/file-context", defineEventHandler(async (event) => {
543
+ const query = getQuery(event);
544
+ const filePath = query["file"];
545
+ const line = Number(query["line"]) || 1;
546
+ const range = Number(query["range"]) || 5;
547
+ if (!filePath || filePath.includes("..") || filePath.startsWith("/")) throw createError({
548
+ statusCode: 400,
549
+ message: "Invalid file path"
550
+ });
551
+ const fullPath = join(projectRoot, filePath);
552
+ if (!existsSync(fullPath)) throw createError({
553
+ statusCode: 404,
554
+ message: "File not found"
555
+ });
556
+ const allLines = (await readFile(fullPath, "utf-8")).split("\n");
557
+ const startLine = Math.max(1, line - range);
558
+ const endLine = Math.min(allLines.length, line + range);
559
+ const lines = [];
560
+ for (let i = startLine; i <= endLine; i++) lines.push({
561
+ num: i,
562
+ content: allLines[i - 1] ?? ""
563
+ });
564
+ return {
565
+ file: filePath,
566
+ lines,
567
+ highlight: line,
568
+ totalLines: allLines.length
569
+ };
570
+ }));
571
+ const MIME_TYPES = {
572
+ html: "text/html",
573
+ js: "application/javascript",
574
+ css: "text/css",
575
+ json: "application/json",
576
+ svg: "image/svg+xml",
577
+ png: "image/png",
578
+ jpg: "image/jpeg",
579
+ ico: "image/x-icon",
580
+ woff2: "font/woff2",
581
+ woff: "font/woff",
582
+ ttf: "font/ttf"
583
+ };
584
+ function getMimeType(filePath) {
585
+ return MIME_TYPES[filePath.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream";
586
+ }
587
+ if (existsSync(uiDir)) {
588
+ const { statSync } = await import("node:fs");
589
+ app.use(defineEventHandler(async (event) => {
590
+ if (event.path.startsWith("/api/") || event.path === "/ws") return;
591
+ let filePath = join(uiDir, event.path === "/" ? "index.html" : event.path);
592
+ if (!existsSync(filePath) || statSync(filePath).isDirectory()) filePath = join(uiDir, "index.html");
593
+ const content = await readFile(filePath);
594
+ event.node.res.setHeader("Content-Type", getMimeType(filePath));
595
+ event.node.res.setHeader("Content-Length", content.length);
596
+ return content;
597
+ }));
598
+ }
599
+ app.use(router);
600
+ return {
601
+ app,
602
+ handleUpgrade,
603
+ toNodeListener: () => toNodeListener(app)
604
+ };
605
+ }
606
+ //#endregion
607
+ export { createServeApp };