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.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/context-An3xrvcP.mjs +50 -0
- package/dist/diff-otz0kGqT.mjs +116 -0
- package/dist/doctor-DyKjAIKH.mjs +187 -0
- package/dist/generate-DDEDRLdy.mjs +76 -0
- package/dist/index.mjs +21 -0
- package/dist/init-D5L_7bKE.mjs +415 -0
- package/dist/serve-CaCZMeHF.mjs +111 -0
- package/dist/serve-ui/assets/AgentPromptGroup.vue_vue_type_script_setup_true_lang-Dy-5bpEY.js +16 -0
- package/dist/serve-ui/assets/AppLayout-CJFq2ApF.js +16 -0
- package/dist/serve-ui/assets/BranchDetailPage-DzSu3JlE.js +17 -0
- package/dist/serve-ui/assets/BranchesPage-ZSG-l44J.js +6 -0
- package/dist/serve-ui/assets/CollapsibleTrigger.vue_vue_type_script_setup_true_lang-C4oejwcu.js +1 -0
- package/dist/serve-ui/assets/Collection-zzx9pNd2.js +6 -0
- package/dist/serve-ui/assets/ConfigProvider-BGjDLViI.js +1 -0
- package/dist/serve-ui/assets/ContentListPage-DTFaQB8N.js +51 -0
- package/dist/serve-ui/assets/ContentListPage-vXxAeSg8.css +1 -0
- package/dist/serve-ui/assets/ContentPage-4sQsAlY2.js +1 -0
- package/dist/serve-ui/assets/DashboardPage-CZDlnvAE.js +21 -0
- package/dist/serve-ui/assets/DialogTitle.vue_vue_type_script_setup_true_lang-CBu_dmA6.js +1 -0
- package/dist/serve-ui/assets/Input.vue_vue_type_script_setup_true_lang-otPltlZG.js +6 -0
- package/dist/serve-ui/assets/ModelDetailPage-BMquKCtH.js +49 -0
- package/dist/serve-ui/assets/ModelsPage-C_K-aWXf.js +1 -0
- package/dist/serve-ui/assets/NormalizePage-CHkT6yLV.js +11 -0
- package/dist/serve-ui/assets/PageHeader.vue_vue_type_script_setup_true_lang-C7aWxNSw.js +11 -0
- package/dist/serve-ui/assets/Presence-cbB6AqkQ.js +3 -0
- package/dist/serve-ui/assets/Separator.vue_vue_type_script_setup_true_lang-fePFl24S.js +1 -0
- package/dist/serve-ui/assets/StudioHint.vue_vue_type_script_setup_true_lang-B45TG2r1.js +6 -0
- package/dist/serve-ui/assets/TrustBadge.vue_vue_type_script_setup_true_lang-CgoQGS6k.js +11 -0
- package/dist/serve-ui/assets/ValidatePage-BW8DO6WC.js +21 -0
- package/dist/serve-ui/assets/arrow-down-DqOfGL7C.js +6 -0
- package/dist/serve-ui/assets/arrow-left-BRvgeveW.js +6 -0
- package/dist/serve-ui/assets/book-marked-D8StxRZP.js +6 -0
- package/dist/serve-ui/assets/book-open-BrGAe3tk.js +6 -0
- package/dist/serve-ui/assets/box-7olVZr5D.js +6 -0
- package/dist/serve-ui/assets/chevron-down-B9YdCtsj.js +6 -0
- package/dist/serve-ui/assets/chevron-right-DFKsmZVd.js +6 -0
- package/dist/serve-ui/assets/content-BQE3NEm5.js +1 -0
- package/dist/serve-ui/assets/database-BGEFn4Oq.js +6 -0
- package/dist/serve-ui/assets/file-code-BGiS4dun.js +6 -0
- package/dist/serve-ui/assets/file-text-BDHXQ95W.js +6 -0
- package/dist/serve-ui/assets/getActiveElement-FUKBWQwh.js +1 -0
- package/dist/serve-ui/assets/git-branch-DdE7bk9-.js +6 -0
- package/dist/serve-ui/assets/git-merge-iStuJS69.js +6 -0
- package/dist/serve-ui/assets/hash-B-ibLpM1.js +6 -0
- package/dist/serve-ui/assets/index-C-RjMzFm.css +1 -0
- package/dist/serve-ui/assets/index-CoUshYEB.js +80 -0
- package/dist/serve-ui/assets/index-WHgvdYOs.js +1 -0
- package/dist/serve-ui/assets/languages-BTcd2Rl1.js +6 -0
- package/dist/serve-ui/assets/model-empty-state-B6AM4bil.js +1 -0
- package/dist/serve-ui/assets/plus-D8Nd3YnG.js +11 -0
- package/dist/serve-ui/assets/project-B1qSm0Fz.js +1 -0
- package/dist/serve-ui/assets/scan-search-DGPL_VJu.js +6 -0
- package/dist/serve-ui/assets/settings-DL-0RaxD.js +6 -0
- package/dist/serve-ui/assets/shield-check-CUL7KkSA.js +6 -0
- package/dist/serve-ui/assets/trash-2-83QhL6-T.js +11 -0
- package/dist/serve-ui/assets/useDirection-Cd3C7VdT.js +1 -0
- package/dist/serve-ui/assets/useFormatters-DgWFpsAv.js +1 -0
- package/dist/serve-ui/assets/useId-C0uR6mdj.js +1 -0
- package/dist/serve-ui/assets/useNonce-DTGvzOJz.js +1 -0
- package/dist/serve-ui/assets/useWatch-COpVIQvm.js +1 -0
- package/dist/serve-ui/favicon.svg +19 -0
- package/dist/serve-ui/for-developers-1.svg +65 -0
- package/dist/serve-ui/icon-color.svg +18 -0
- package/dist/serve-ui/index.html +17 -0
- package/dist/serve-ui/logo/color-icon-black-text.svg +65 -0
- package/dist/serve-ui/logo/color-icon-white-text.svg +61 -0
- package/dist/serve-ui/merge-1.svg +131 -0
- package/dist/serve-ui/merge-2.svg +161 -0
- package/dist/serve-ui/model-empty-state.svg +12 -0
- package/dist/serve-ui/select-data-type.svg +23 -0
- package/dist/server-DW0KXB0T.mjs +607 -0
- package/dist/status-kF5miVty.mjs +122 -0
- package/dist/ui-B5mXontH.mjs +33 -0
- package/dist/validate-DnVamp3p.mjs +155 -0
- 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 };
|