@contentrain/mcp 0.3.1 → 1.0.3

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 (115) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +154 -238
  3. package/dist/apply-manager-BAeqrAHX.mjs +751 -0
  4. package/dist/apply-manager-BAeqrAHX.mjs.map +1 -0
  5. package/dist/astro-parser-RofKmKQi.mjs +166 -0
  6. package/dist/astro-parser-RofKmKQi.mjs.map +1 -0
  7. package/dist/branch-lifecycle-DB6sgeJi.mjs +97 -0
  8. package/dist/branch-lifecycle-DB6sgeJi.mjs.map +1 -0
  9. package/dist/chunk-BEJ448es.mjs +35 -0
  10. package/dist/compiler-rIC4ETHv.mjs +44509 -0
  11. package/dist/compiler-rIC4ETHv.mjs.map +1 -0
  12. package/dist/compiler-sfc.cjs-BbD04rn_.mjs +50279 -0
  13. package/dist/compiler-sfc.cjs-BbD04rn_.mjs.map +1 -0
  14. package/dist/config-CPlhDwsv.mjs +27 -0
  15. package/dist/config-CPlhDwsv.mjs.map +1 -0
  16. package/dist/context-CRBCTYPB.mjs +46 -0
  17. package/dist/context-CRBCTYPB.mjs.map +1 -0
  18. package/dist/core/apply-manager.d.mts +144 -0
  19. package/dist/core/apply-manager.d.mts.map +1 -0
  20. package/dist/core/apply-manager.mjs +6 -0
  21. package/dist/core/config.d.mts +8 -0
  22. package/dist/core/config.d.mts.map +1 -0
  23. package/dist/core/config.mjs +2 -0
  24. package/dist/core/content-manager.d.mts +53 -0
  25. package/dist/core/content-manager.d.mts.map +1 -0
  26. package/dist/core/content-manager.mjs +2 -0
  27. package/dist/core/context.d.mts +13 -0
  28. package/dist/core/context.d.mts.map +1 -0
  29. package/dist/core/context.mjs +3 -0
  30. package/dist/core/graph-builder.d.mts +12 -0
  31. package/dist/core/graph-builder.d.mts.map +1 -0
  32. package/dist/core/graph-builder.mjs +2 -0
  33. package/dist/core/meta-manager.d.mts +14 -0
  34. package/dist/core/meta-manager.d.mts.map +1 -0
  35. package/dist/core/meta-manager.mjs +2 -0
  36. package/dist/core/model-manager.d.mts +44 -0
  37. package/dist/core/model-manager.d.mts.map +1 -0
  38. package/dist/core/model-manager.mjs +2 -0
  39. package/dist/core/scan-config.d.mts +24 -0
  40. package/dist/core/scan-config.d.mts.map +1 -0
  41. package/dist/core/scan-config.mjs +2 -0
  42. package/dist/core/scanner.d.mts +25 -0
  43. package/dist/core/scanner.d.mts.map +1 -0
  44. package/dist/core/scanner.mjs +3 -0
  45. package/dist/core/validator.d.mts +23 -0
  46. package/dist/core/validator.d.mts.map +1 -0
  47. package/dist/core/validator.mjs +3 -0
  48. package/dist/detect-C8XmJ4S6.mjs +362 -0
  49. package/dist/detect-C8XmJ4S6.mjs.map +1 -0
  50. package/dist/fs-BbuGnVcG.mjs +52 -0
  51. package/dist/fs-BbuGnVcG.mjs.map +1 -0
  52. package/dist/git/branch-lifecycle.d.mts +28 -0
  53. package/dist/git/branch-lifecycle.d.mts.map +1 -0
  54. package/dist/git/branch-lifecycle.mjs +2 -0
  55. package/dist/git/transaction.d.mts +25 -0
  56. package/dist/git/transaction.d.mts.map +1 -0
  57. package/dist/git/transaction.mjs +4 -0
  58. package/dist/graph-builder-fPrDDwnu.mjs +162 -0
  59. package/dist/graph-builder-fPrDDwnu.mjs.map +1 -0
  60. package/dist/id-BdD4HvMY.mjs +12 -0
  61. package/dist/id-BdD4HvMY.mjs.map +1 -0
  62. package/dist/index.d.mts +1 -359
  63. package/dist/index.mjs +15 -2161
  64. package/dist/index.mjs.map +1 -1
  65. package/dist/meta-manager-CECrfsKm.mjs +53 -0
  66. package/dist/meta-manager-CECrfsKm.mjs.map +1 -0
  67. package/dist/model-manager-sfPdrVIJ.mjs +1016 -0
  68. package/dist/model-manager-sfPdrVIJ.mjs.map +1 -0
  69. package/dist/node-Cbq5eR5i.mjs +300 -0
  70. package/dist/node-Cbq5eR5i.mjs.map +1 -0
  71. package/dist/scan-config-AJGxCdNd.mjs +159 -0
  72. package/dist/scan-config-AJGxCdNd.mjs.map +1 -0
  73. package/dist/scanner-BdTiv8sw.mjs +619 -0
  74. package/dist/scanner-BdTiv8sw.mjs.map +1 -0
  75. package/dist/serializer-Dueih-i2.mjs +33 -0
  76. package/dist/serializer-Dueih-i2.mjs.map +1 -0
  77. package/dist/server-CC12z_xq.mjs +1868 -0
  78. package/dist/server-CC12z_xq.mjs.map +1 -0
  79. package/dist/server.d.mts +7 -0
  80. package/dist/server.d.mts.map +1 -0
  81. package/dist/server.mjs +11 -0
  82. package/dist/source-map-generator-DpO-ejrt.mjs +893 -0
  83. package/dist/source-map-generator-DpO-ejrt.mjs.map +1 -0
  84. package/dist/svelte-parser-COTNG92w.mjs +225 -0
  85. package/dist/svelte-parser-COTNG92w.mjs.map +1 -0
  86. package/dist/templates/index.d.mts +8 -0
  87. package/dist/templates/index.d.mts.map +1 -0
  88. package/dist/templates/index.mjs +2 -0
  89. package/dist/templates-3HWi-PTu.mjs +824 -0
  90. package/dist/templates-3HWi-PTu.mjs.map +1 -0
  91. package/dist/transaction-DXkMulzr.mjs +128 -0
  92. package/dist/transaction-DXkMulzr.mjs.map +1 -0
  93. package/dist/tsx-parser-Bf8Firrq.mjs +2 -0
  94. package/dist/tsx-parser-CYVyBWjn.mjs +234 -0
  95. package/dist/tsx-parser-CYVyBWjn.mjs.map +1 -0
  96. package/dist/util/detect.d.mts +20 -0
  97. package/dist/util/detect.d.mts.map +1 -0
  98. package/dist/util/detect.mjs +2 -0
  99. package/dist/util/fs.d.mts +12 -0
  100. package/dist/util/fs.d.mts.map +1 -0
  101. package/dist/util/fs.mjs +2 -0
  102. package/dist/util/id.d.mts +6 -0
  103. package/dist/util/id.d.mts.map +1 -0
  104. package/dist/util/id.mjs +2 -0
  105. package/dist/util/serializer.d.mts +14 -0
  106. package/dist/util/serializer.d.mts.map +1 -0
  107. package/dist/util/serializer.mjs +2 -0
  108. package/dist/validator-C3lZqO3U.mjs +755 -0
  109. package/dist/validator-C3lZqO3U.mjs.map +1 -0
  110. package/dist/vue-parser-CjS4YAoQ.mjs +225 -0
  111. package/dist/vue-parser-CjS4YAoQ.mjs.map +1 -0
  112. package/package.json +113 -43
  113. package/dist/fs-D83-Ii_y.mjs +0 -75
  114. package/dist/fs-D83-Ii_y.mjs.map +0 -1
  115. package/dist/index.d.mts.map +0 -1
package/dist/index.mjs CHANGED
@@ -1,2167 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import { a as removeDir, i as listJsonFiles, n as fileExists, o as removeFile, t as ensureDir } from "./fs-D83-Ii_y.mjs";
2
+ import "./model-manager-sfPdrVIJ.mjs";
3
+ import "./context-CRBCTYPB.mjs";
4
+ import "./branch-lifecycle-DB6sgeJi.mjs";
5
+ import { t as createServer } from "./server-CC12z_xq.mjs";
6
+ import "./transaction-DXkMulzr.mjs";
7
+ import "./validator-C3lZqO3U.mjs";
8
+ import "./graph-builder-fPrDDwnu.mjs";
9
+ import "./scanner-BdTiv8sw.mjs";
10
+ import "./tsx-parser-CYVyBWjn.mjs";
11
+ import "./apply-manager-BAeqrAHX.mjs";
12
+ import { resolve } from "node:path";
3
13
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
- import { dirname, join } from "node:path";
6
- import { cp, mkdtemp, readFile, rename, stat, writeFile } from "node:fs/promises";
7
- import { tmpdir } from "node:os";
8
- import { randomBytes, randomUUID } from "node:crypto";
9
- import { simpleGit } from "simple-git";
10
- import { z } from "zod";
11
- //#region src/util/json.ts
12
- /**
13
- * Read and parse a JSON file. Returns empty array if file doesn't exist.
14
- */
15
- async function readJsonFile(filePath) {
16
- try {
17
- const raw = await readFile(filePath, "utf-8");
18
- return JSON.parse(raw);
19
- } catch (err) {
20
- if (err instanceof Error && "code" in err && err.code === "ENOENT") return [];
21
- throw err;
22
- }
23
- }
24
- /**
25
- * Write JSON to file atomically (temp file + rename).
26
- */
27
- async function writeJsonFile(filePath, data) {
28
- await ensureDir(dirname(filePath));
29
- const tmpPath = join(dirname(filePath), `.tmp-${randomBytes(4).toString("hex")}.json`);
30
- await writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
31
- await rename(tmpPath, filePath);
32
- }
33
- //#endregion
34
- //#region src/errors.ts
35
- var ContentrainError = class extends Error {
36
- code;
37
- context;
38
- constructor(message, code, context = {}) {
39
- super(message);
40
- this.name = "ContentrainError";
41
- this.code = code;
42
- this.context = context;
43
- }
44
- };
45
- var ModelNotFoundError = class extends ContentrainError {
46
- constructor(modelId) {
47
- super(`Model not found: ${modelId}`, "MODEL_NOT_FOUND", { modelId });
48
- this.name = "ModelNotFoundError";
49
- }
50
- };
51
- var EntryNotFoundError = class extends ContentrainError {
52
- constructor(modelId, entryId) {
53
- super(`Entry not found: ${entryId} in model ${modelId}`, "ENTRY_NOT_FOUND", {
54
- modelId,
55
- entryId
56
- });
57
- this.name = "EntryNotFoundError";
58
- }
59
- };
60
- var GitSyncError = class extends ContentrainError {
61
- constructor(message, context = {}) {
62
- super(message, "GIT_SYNC_ERROR", context);
63
- this.name = "GitSyncError";
64
- }
65
- };
66
- var PushRejectedError = class extends GitSyncError {
67
- attemptsExhausted;
68
- constructor(attemptsExhausted) {
69
- super(attemptsExhausted ? "Push rejected: all retry attempts exhausted" : "Push rejected by remote", { attemptsExhausted });
70
- this.name = "PushRejectedError";
71
- this.attemptsExhausted = attemptsExhausted;
72
- }
73
- };
74
- var AssetNotFoundError = class extends ContentrainError {
75
- constructor(assetPath) {
76
- super(`Asset not found in registry: ${assetPath}`, "ASSET_NOT_FOUND", { assetPath });
77
- this.name = "AssetNotFoundError";
78
- }
79
- };
80
- //#endregion
81
- //#region src/core/model/system-fields.ts
82
- /**
83
- * Generate the 4 required system fields for a model.
84
- */
85
- function createSystemFields(modelId) {
86
- return [
87
- {
88
- name: "createdAt",
89
- fieldId: "createdAt",
90
- componentId: "date",
91
- options: {},
92
- validations: { "required-field": { value: true } },
93
- fieldType: "date",
94
- system: true,
95
- defaultField: false,
96
- modelId
97
- },
98
- {
99
- name: "updatedAt",
100
- fieldId: "updatedAt",
101
- componentId: "date",
102
- options: {},
103
- validations: { "required-field": { value: true } },
104
- fieldType: "date",
105
- system: true,
106
- defaultField: false,
107
- modelId
108
- },
109
- {
110
- name: "ID",
111
- fieldId: "ID",
112
- componentId: "single-line-text",
113
- options: {},
114
- validations: { "required-field": { value: true } },
115
- fieldType: "string",
116
- system: true,
117
- defaultField: false,
118
- modelId
119
- },
120
- {
121
- name: "status",
122
- fieldId: "status",
123
- componentId: "single-line-text",
124
- options: {},
125
- validations: { "required-field": { value: true } },
126
- fieldType: "string",
127
- system: true,
128
- defaultField: false,
129
- modelId
130
- }
131
- ];
132
- }
133
- /**
134
- * Generate MD/MDX default fields (slug + content).
135
- */
136
- function createMdDefaultFields(modelId) {
137
- return [{
138
- name: "slug",
139
- fieldId: "slug",
140
- componentId: "slug",
141
- options: {},
142
- validations: {
143
- "required-field": { value: true },
144
- "unique-field": { value: true }
145
- },
146
- fieldType: "string",
147
- system: false,
148
- defaultField: true,
149
- modelId
150
- }, {
151
- name: "content",
152
- fieldId: "content",
153
- componentId: "md-editor",
154
- options: {},
155
- validations: { "required-field": { value: false } },
156
- fieldType: "string",
157
- system: false,
158
- defaultField: true,
159
- modelId
160
- }];
161
- }
162
- //#endregion
163
- //#region src/core/model/manager.ts
164
- var ModelManager = class {
165
- constructor(basePath) {
166
- this.basePath = basePath;
167
- }
168
- get modelsDir() {
169
- return join(this.basePath, "models");
170
- }
171
- get metadataPath() {
172
- return join(this.modelsDir, "metadata.json");
173
- }
174
- async list() {
175
- return readJsonFile(this.metadataPath);
176
- }
177
- async get(modelId) {
178
- const model = (await this.list()).find((m) => m.modelId === modelId);
179
- if (!model) throw new ModelNotFoundError(modelId);
180
- return model;
181
- }
182
- async getFields(modelId) {
183
- await this.get(modelId);
184
- return readJsonFile(join(this.modelsDir, `${modelId}.json`));
185
- }
186
- async describe(modelId) {
187
- const model = await this.get(modelId);
188
- const fields = await this.getFields(modelId);
189
- return {
190
- model,
191
- fields,
192
- customFields: fields.filter((f) => !f.system && !f.defaultField)
193
- };
194
- }
195
- async create(input) {
196
- const models = await this.list();
197
- if (models.some((m) => m.modelId === input.modelId)) throw new Error(`Model already exists: ${input.modelId}`);
198
- const metadata = {
199
- name: input.name,
200
- description: input.description,
201
- modelId: input.modelId,
202
- path: input.path,
203
- localization: input.localization,
204
- type: input.type,
205
- createdBy: "user",
206
- isServerless: false
207
- };
208
- models.push(metadata);
209
- await writeJsonFile(this.metadataPath, models);
210
- const systemFields = createSystemFields(input.modelId);
211
- const mdFields = input.type === "MD" || input.type === "MDX" ? createMdDefaultFields(input.modelId) : [];
212
- const customFields = (input.fields || []).map((f) => ({
213
- ...f,
214
- modelId: input.modelId,
215
- system: false,
216
- defaultField: false
217
- }));
218
- const allFields = [
219
- ...systemFields,
220
- ...mdFields,
221
- ...customFields
222
- ];
223
- await ensureDir(this.modelsDir);
224
- await writeJsonFile(join(this.modelsDir, `${input.modelId}.json`), allFields);
225
- const contentDir = join(this.basePath, input.modelId);
226
- await ensureDir(contentDir);
227
- if (input.localization) {} else await writeJsonFile(join(contentDir, `${input.modelId}.json`), []);
228
- return metadata;
229
- }
230
- async addField(modelId, field) {
231
- const fields = await this.getFields(modelId);
232
- if (fields.some((f) => f.fieldId === field.fieldId)) throw new Error(`Field already exists: ${field.fieldId} in model ${modelId}`);
233
- const newField = {
234
- ...field,
235
- modelId,
236
- system: false,
237
- defaultField: false
238
- };
239
- fields.push(newField);
240
- await writeJsonFile(join(this.modelsDir, `${modelId}.json`), fields);
241
- return newField;
242
- }
243
- async removeField(modelId, fieldId) {
244
- const fields = await this.getFields(modelId);
245
- const field = fields.find((f) => f.fieldId === fieldId);
246
- if (!field) throw new Error(`Field not found: ${fieldId} in model ${modelId}`);
247
- if (field.system) throw new Error(`Cannot remove system field: ${fieldId}`);
248
- const filtered = fields.filter((f) => f.fieldId !== fieldId);
249
- await writeJsonFile(join(this.modelsDir, `${modelId}.json`), filtered);
250
- }
251
- async delete(modelId) {
252
- const models = await this.list();
253
- const filtered = models.filter((m) => m.modelId !== modelId);
254
- if (filtered.length === models.length) throw new ModelNotFoundError(modelId);
255
- await writeJsonFile(this.metadataPath, filtered);
256
- const { removeFile, removeDir } = await import("./fs-D83-Ii_y.mjs").then((n) => n.r);
257
- await removeFile(join(this.modelsDir, `${modelId}.json`));
258
- await removeDir(join(this.basePath, modelId));
259
- }
260
- };
261
- //#endregion
262
- //#region src/util/id.ts
263
- const ID_PATTERN = /^[a-f0-9]{8,12}$/;
264
- /**
265
- * Generate a 12-character lowercase hexadecimal ID.
266
- * Matches the Contentrain platform: crypto.randomUUID().split('-').pop()
267
- */
268
- function generateId() {
269
- return randomUUID().split("-").pop();
270
- }
271
- /**
272
- * Validate that a string matches the Contentrain ID format.
273
- */
274
- function isValidId(id) {
275
- return ID_PATTERN.test(id);
276
- }
277
- //#endregion
278
- //#region src/core/content/markdown.ts
279
- /**
280
- * Build frontmatter string from entry data (excluding 'content' field).
281
- */
282
- function buildFrontmatter(entry) {
283
- const lines = ["---"];
284
- for (const [key, value] of Object.entries(entry)) {
285
- if (key === "content") continue;
286
- if (typeof value === "string") lines.push(`${key}: '${value}'`);
287
- else if (typeof value === "boolean" || typeof value === "number") lines.push(`${key}: ${value}`);
288
- else if (Array.isArray(value)) lines.push(`${key}: ${JSON.stringify(value)}`);
289
- }
290
- lines.push("---");
291
- return lines.join("\n");
292
- }
293
- /**
294
- * Get the markdown file extension based on model type.
295
- */
296
- function getExtension(type) {
297
- return type === "MDX" ? ".mdx" : ".md";
298
- }
299
- /**
300
- * Resolve the markdown file path for an entry.
301
- */
302
- function resolveMarkdownPath(repoPath, model, entry, locale) {
303
- const basePath = entry.path || model.path || "";
304
- const ext = getExtension(model.type);
305
- if (locale) return join(repoPath, basePath, locale, `${entry.slug}${ext}`);
306
- return join(repoPath, basePath, `${entry.slug}${ext}`);
307
- }
308
- var MarkdownSync = class {
309
- constructor(repoPath) {
310
- this.repoPath = repoPath;
311
- }
312
- /**
313
- * Write a markdown file for an MD/MDX entry.
314
- */
315
- async write(model, entry, content, locale) {
316
- const filePath = resolveMarkdownPath(this.repoPath, model, entry, locale);
317
- await ensureDir(join(filePath, ".."));
318
- await writeFile(filePath, `${buildFrontmatter(entry)}\n\n${content || ""}\n`, "utf-8");
319
- return filePath;
320
- }
321
- /**
322
- * Read markdown content from a file.
323
- */
324
- async read(model, entry, locale) {
325
- const filePath = resolveMarkdownPath(this.repoPath, model, entry, locale);
326
- try {
327
- const match = (await readFile(filePath, "utf-8")).match(/^---\n[\s\S]*?\n---\n\n?([\s\S]*)$/);
328
- return match ? match[1].trim() : "";
329
- } catch {
330
- return "";
331
- }
332
- }
333
- /**
334
- * Delete a markdown file for an entry.
335
- */
336
- async delete(model, entry, locale) {
337
- await removeFile(resolveMarkdownPath(this.repoPath, model, entry, locale));
338
- }
339
- };
340
- //#endregion
341
- //#region src/core/content/manager.ts
342
- var ContentManager = class {
343
- modelManager;
344
- markdownSync;
345
- constructor(basePath, repoPath) {
346
- this.basePath = basePath;
347
- this.repoPath = repoPath;
348
- this.modelManager = new ModelManager(basePath);
349
- this.markdownSync = new MarkdownSync(repoPath);
350
- }
351
- contentDir(modelId) {
352
- return join(this.basePath, modelId);
353
- }
354
- contentFilePath(modelId, locale) {
355
- const dir = this.contentDir(modelId);
356
- return locale ? join(dir, `${locale}.json`) : join(dir, `${modelId}.json`);
357
- }
358
- async list(modelId, locale) {
359
- const model = await this.modelManager.get(modelId);
360
- return readJsonFile(this.resolveContentPath(model, locale));
361
- }
362
- async get(modelId, entryId, locale) {
363
- const entry = (await this.list(modelId, locale)).find((e) => e.ID === entryId);
364
- if (!entry) throw new EntryNotFoundError(modelId, entryId);
365
- return entry;
366
- }
367
- async getLocales(modelId) {
368
- if (!(await this.modelManager.get(modelId)).localization) return [];
369
- return (await listJsonFiles(this.contentDir(modelId))).filter((f) => f !== modelId);
370
- }
371
- async create(modelId, data, options) {
372
- const model = await this.modelManager.get(modelId);
373
- const id = options?.id || generateId();
374
- const now = (/* @__PURE__ */ new Date()).toISOString();
375
- const entry = {
376
- ID: id,
377
- createdAt: now,
378
- updatedAt: now,
379
- status: options?.status || "draft",
380
- scheduled: false,
381
- ...data
382
- };
383
- if (model.localization) await this.createLocalized(model, entry, options?.locale, options?.content);
384
- else await this.appendEntry(model, entry, void 0, options?.content);
385
- return id;
386
- }
387
- async update(modelId, entryId, data, options) {
388
- const model = await this.modelManager.get(modelId);
389
- const locale = model.localization ? options?.locale : void 0;
390
- const filePath = this.resolveContentPath(model, locale);
391
- const entries = await readJsonFile(filePath);
392
- const index = entries.findIndex((e) => e.ID === entryId);
393
- if (index === -1) throw new EntryNotFoundError(modelId, entryId);
394
- const updated = {
395
- ...entries[index],
396
- ...data,
397
- ID: entryId,
398
- createdAt: entries[index].createdAt,
399
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
400
- };
401
- entries[index] = updated;
402
- await writeJsonFile(filePath, entries);
403
- if ((model.type === "MD" || model.type === "MDX") && "slug" in updated) {
404
- const mdContent = options?.content ?? await this.markdownSync.read(model, updated, locale);
405
- await this.markdownSync.write(model, updated, mdContent, locale);
406
- }
407
- return updated;
408
- }
409
- async delete(modelId, entryId) {
410
- const model = await this.modelManager.get(modelId);
411
- if (model.localization) {
412
- const locales = await this.getLocales(modelId);
413
- for (const locale of locales) await this.deleteFromFile(model, entryId, locale);
414
- } else await this.deleteFromFile(model, entryId);
415
- }
416
- resolveContentPath(model, locale) {
417
- if (model.localization && locale) return this.contentFilePath(model.modelId, locale);
418
- if (model.localization && !locale) return this.contentFilePath(model.modelId, model.modelId);
419
- return this.contentFilePath(model.modelId);
420
- }
421
- async createLocalized(model, entry, locale, content) {
422
- if (locale) await this.appendEntry(model, entry, locale, content);
423
- else {
424
- const locales = await this.getLocales(model.modelId);
425
- if (locales.length === 0) throw new Error(`Model "${model.modelId}" is localized but no locale files exist. Specify a locale (e.g., locale: "en") to create the first entry.`);
426
- for (const loc of locales) await this.appendEntry(model, entry, loc, content);
427
- }
428
- }
429
- async appendEntry(model, entry, locale, content) {
430
- const filePath = this.resolveContentPath(model, locale);
431
- await ensureDir(join(filePath, ".."));
432
- const entries = await readJsonFile(filePath);
433
- if (entries.some((e) => e.ID === entry.ID)) throw new Error(`Duplicate ID: ${entry.ID} already exists in ${model.modelId}`);
434
- entries.push(entry);
435
- await writeJsonFile(filePath, entries);
436
- if ((model.type === "MD" || model.type === "MDX") && "slug" in entry) await this.markdownSync.write(model, entry, content || "", locale);
437
- }
438
- async deleteFromFile(model, entryId, locale) {
439
- const filePath = this.resolveContentPath(model, locale);
440
- const entries = await readJsonFile(filePath);
441
- const entry = entries.find((e) => e.ID === entryId);
442
- if (!entry) return;
443
- await writeJsonFile(filePath, entries.filter((e) => e.ID !== entryId));
444
- if ((model.type === "MD" || model.type === "MDX") && "slug" in entry) await this.markdownSync.delete(model, entry, locale);
445
- }
446
- };
447
- //#endregion
448
- //#region src/core/asset/manager.ts
449
- var AssetManager = class {
450
- assetsPath;
451
- constructor(basePath, repoPath, config) {
452
- this.basePath = basePath;
453
- this.repoPath = repoPath;
454
- this.config = config;
455
- this.assetsPath = join(basePath, "assets.json");
456
- }
457
- async list() {
458
- return readJsonFile(this.assetsPath);
459
- }
460
- async get(assetPath) {
461
- return (await this.list()).find((a) => a.path === assetPath);
462
- }
463
- async register(input) {
464
- const fullPath = join(this.repoPath, input.path);
465
- if (!await fileExists(fullPath)) throw new Error(`Asset file does not exist at: ${input.path}`);
466
- const assets = await this.list();
467
- if (assets.some((a) => a.path === input.path)) throw new Error(`Asset already registered: ${input.path}`);
468
- const stats = await stat(fullPath);
469
- const now = (/* @__PURE__ */ new Date()).toISOString();
470
- const entry = {
471
- path: input.path,
472
- mimetype: input.mimetype,
473
- size: stats.size,
474
- alt: input.alt || "",
475
- meta: {
476
- user: {
477
- name: this.config.author?.name || "AI Agent",
478
- email: this.config.author?.email || "agent@contentrain.io"
479
- },
480
- createdAt: now,
481
- updatedAt: now
482
- }
483
- };
484
- assets.push(entry);
485
- await writeJsonFile(this.assetsPath, assets);
486
- return entry;
487
- }
488
- async deregister(assetPath) {
489
- const assets = await this.list();
490
- const filtered = assets.filter((a) => a.path !== assetPath);
491
- if (filtered.length === assets.length) throw new AssetNotFoundError(assetPath);
492
- await writeJsonFile(this.assetsPath, filtered);
493
- }
494
- async update(assetPath, updates) {
495
- const assets = await this.list();
496
- const index = assets.findIndex((a) => a.path === assetPath);
497
- if (index === -1) throw new AssetNotFoundError(assetPath);
498
- if (updates.alt !== void 0) assets[index].alt = updates.alt;
499
- if (updates.mimetype !== void 0) assets[index].mimetype = updates.mimetype;
500
- if (assets[index].meta) assets[index].meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
501
- await writeJsonFile(this.assetsPath, assets);
502
- return assets[index];
503
- }
504
- };
505
- //#endregion
506
- //#region src/core/validation/rules.ts
507
- const VALID_STATUSES = [
508
- "draft",
509
- "publish",
510
- "changed"
511
- ];
512
- /**
513
- * Map componentId to expected runtime JS type.
514
- */
515
- function getExpectedType(componentId, fieldType) {
516
- switch (componentId) {
517
- case "single-line-text":
518
- case "multi-line-text":
519
- case "email":
520
- case "url":
521
- case "slug":
522
- case "color":
523
- case "json":
524
- case "md-editor":
525
- case "rich-text-editor":
526
- case "phone-number": return "string";
527
- case "integer":
528
- case "decimal":
529
- case "rating":
530
- case "percent": return "number";
531
- case "checkbox":
532
- case "switch": return "boolean";
533
- case "date":
534
- case "date-time": return "string";
535
- case "media": return "string";
536
- case "one-to-one": return "string";
537
- case "one-to-many": return "array";
538
- default: return fieldType === "array" ? "array" : "string";
539
- }
540
- }
541
- /**
542
- * Validate an ID matches Contentrain format (12-char lowercase hex).
543
- */
544
- function validateId(id) {
545
- if (typeof id !== "string") return "ID must be a string";
546
- if (!isValidId(id)) return "ID must be 8–12 character lowercase hexadecimal";
547
- return null;
548
- }
549
- /**
550
- * Validate a status value.
551
- */
552
- function validateStatus(status) {
553
- if (typeof status !== "string") return "status must be a string";
554
- if (!VALID_STATUSES.includes(status)) return `status must be one of: ${VALID_STATUSES.join(", ")}`;
555
- return null;
556
- }
557
- /**
558
- * Validate a value matches the expected runtime type.
559
- */
560
- function validateFieldValue(value, expectedType, fieldId) {
561
- if (value === null || value === void 0) return null;
562
- switch (expectedType) {
563
- case "string":
564
- if (typeof value !== "string") return `${fieldId} must be a string`;
565
- break;
566
- case "number":
567
- if (typeof value !== "number") return `${fieldId} must be a number`;
568
- break;
569
- case "boolean":
570
- if (typeof value !== "boolean") return `${fieldId} must be a boolean`;
571
- break;
572
- case "array":
573
- if (!Array.isArray(value)) return `${fieldId} must be an array`;
574
- break;
575
- }
576
- return null;
577
- }
578
- /**
579
- * Validate integer specifically.
580
- */
581
- function validateInteger(value, fieldId) {
582
- if (typeof value === "number" && !Number.isInteger(value)) return `${fieldId} must be an integer`;
583
- return null;
584
- }
585
- //#endregion
586
- //#region src/core/validation/validator.ts
587
- var Validator = class {
588
- modelManager;
589
- constructor(basePath) {
590
- this.basePath = basePath;
591
- this.modelManager = new ModelManager(basePath);
592
- }
593
- /**
594
- * Validate content data against model field definitions.
595
- */
596
- async validate(modelId, data, options) {
597
- const errors = [];
598
- const { fields, model } = await this.modelManager.describe(modelId);
599
- const nonSystemFields = fields.filter((f) => !f.system);
600
- for (const field of nonSystemFields) {
601
- const value = data[field.fieldId];
602
- if (field.validations?.["required-field"]?.value) {
603
- if (!options?.isUpdate && (value === void 0 || value === null || value === "")) {
604
- if (field.fieldId === "content" && (model.type === "MD" || model.type === "MDX")) continue;
605
- errors.push({
606
- fieldId: field.fieldId,
607
- message: `${field.name} is required`,
608
- rule: "required-field"
609
- });
610
- continue;
611
- }
612
- }
613
- if (value === void 0 || value === null) continue;
614
- const typeError = validateFieldValue(value, getExpectedType(field.componentId, field.fieldType), field.fieldId);
615
- if (typeError) {
616
- errors.push({
617
- fieldId: field.fieldId,
618
- message: typeError,
619
- rule: "type-check"
620
- });
621
- continue;
622
- }
623
- if (field.componentId === "integer") {
624
- const intError = validateInteger(value, field.fieldId);
625
- if (intError) errors.push({
626
- fieldId: field.fieldId,
627
- message: intError,
628
- rule: "integer-check"
629
- });
630
- }
631
- if (field.componentId === "one-to-one" && typeof value === "string") {
632
- const refModel = field.options?.reference?.form?.reference?.value;
633
- if (refModel) {
634
- const integrityError = await this.checkRelationIntegrity(refModel, value);
635
- if (integrityError) errors.push({
636
- fieldId: field.fieldId,
637
- message: integrityError,
638
- rule: "relation-integrity"
639
- });
640
- }
641
- }
642
- if (field.componentId === "one-to-many" && Array.isArray(value)) {
643
- const refModel = field.options?.reference?.form?.reference?.value;
644
- if (refModel) {
645
- for (const refId of value) if (typeof refId === "string") {
646
- const integrityError = await this.checkRelationIntegrity(refModel, refId);
647
- if (integrityError) errors.push({
648
- fieldId: field.fieldId,
649
- message: integrityError,
650
- rule: "relation-integrity"
651
- });
652
- }
653
- }
654
- }
655
- if (field.componentId === "media" && typeof value === "string" && value !== "") {
656
- const assetError = await this.checkAssetExists(value);
657
- if (assetError) errors.push({
658
- fieldId: field.fieldId,
659
- message: assetError,
660
- rule: "asset-exists"
661
- });
662
- }
663
- }
664
- if (!options?.isUpdate) {
665
- const uniqueFields = nonSystemFields.filter((f) => f.validations?.["unique-field"]?.value);
666
- for (const field of uniqueFields) {
667
- const value = data[field.fieldId];
668
- if (value === void 0 || value === null) continue;
669
- const uniqueError = await this.checkUniqueness(modelId, field.fieldId, value);
670
- if (uniqueError) errors.push({
671
- fieldId: field.fieldId,
672
- message: uniqueError,
673
- rule: "unique-field"
674
- });
675
- }
676
- }
677
- return {
678
- valid: errors.length === 0,
679
- errors
680
- };
681
- }
682
- /**
683
- * Validate ID format.
684
- */
685
- validateId(id) {
686
- const error = validateId(id);
687
- if (error) return {
688
- valid: false,
689
- errors: [{
690
- fieldId: "ID",
691
- message: error,
692
- rule: "id-format"
693
- }]
694
- };
695
- return {
696
- valid: true,
697
- errors: []
698
- };
699
- }
700
- /**
701
- * Validate status value.
702
- */
703
- validateStatus(status) {
704
- const error = validateStatus(status);
705
- if (error) return {
706
- valid: false,
707
- errors: [{
708
- fieldId: "status",
709
- message: error,
710
- rule: "status-value"
711
- }]
712
- };
713
- return {
714
- valid: true,
715
- errors: []
716
- };
717
- }
718
- async checkRelationIntegrity(refModelId, refEntryId) {
719
- try {
720
- await this.modelManager.get(refModelId);
721
- const contentDir = join(this.basePath, refModelId);
722
- const { listJsonFiles } = await import("./fs-D83-Ii_y.mjs").then((n) => n.r);
723
- const files = await listJsonFiles(contentDir);
724
- for (const file of files) if ((await readJsonFile(join(contentDir, `${file}.json`))).some((e) => e.ID === refEntryId)) return null;
725
- return `Referenced entry ${refEntryId} not found in model ${refModelId}`;
726
- } catch {
727
- return `Referenced model ${refModelId} not found`;
728
- }
729
- }
730
- async checkAssetExists(assetPath) {
731
- try {
732
- if ((await readJsonFile(join(this.basePath, "assets.json"))).some((a) => a.path === assetPath)) return null;
733
- return `Asset not found in registry: ${assetPath}`;
734
- } catch {
735
- return `Could not read assets.json`;
736
- }
737
- }
738
- async checkUniqueness(modelId, fieldId, value) {
739
- try {
740
- const contentDir = join(this.basePath, modelId);
741
- const { listJsonFiles } = await import("./fs-D83-Ii_y.mjs").then((n) => n.r);
742
- const files = await listJsonFiles(contentDir);
743
- for (const file of files) if ((await readJsonFile(join(contentDir, `${file}.json`))).some((e) => e[fieldId] === value)) return `${fieldId} value "${value}" already exists — must be unique`;
744
- return null;
745
- } catch {
746
- return null;
747
- }
748
- }
749
- };
750
- //#endregion
751
- //#region src/core/git/merge.ts
752
- /**
753
- * Three-way structural merge for JSON arrays indexed by ID.
754
- *
755
- * Strategy:
756
- * - New entries (in ours but not base): APPEND to result
757
- * - Deleted entries (in base but not in ours): REMOVE from result
758
- * - Modified entries (same ID, different fields): apply our field-level changes over theirs
759
- * - Entries only in theirs (added by another writer): KEEP
760
- */
761
- function structuralMerge(base, ours, theirs) {
762
- const baseMap = indexById(base);
763
- const oursMap = indexById(ours);
764
- const result = [];
765
- const processedIds = /* @__PURE__ */ new Set();
766
- for (const theirEntry of theirs) {
767
- const id = theirEntry.ID;
768
- processedIds.add(id);
769
- const baseEntry = baseMap.get(id);
770
- const ourEntry = oursMap.get(id);
771
- if (!ourEntry && baseEntry) continue;
772
- if (ourEntry && baseEntry) result.push(mergeEntry(baseEntry, ourEntry, theirEntry));
773
- else result.push(theirEntry);
774
- }
775
- for (const ourEntry of ours) if (!processedIds.has(ourEntry.ID)) result.push(ourEntry);
776
- return result;
777
- }
778
- /**
779
- * Merge a single entry using three-way field comparison.
780
- * Our changes take precedence over theirs when both modified the same field.
781
- */
782
- function mergeEntry(base, ours, theirs) {
783
- const merged = { ...theirs };
784
- for (const key of Object.keys(ours)) {
785
- const baseVal = JSON.stringify(base[key]);
786
- const ourVal = JSON.stringify(ours[key]);
787
- const theirVal = JSON.stringify(theirs[key]);
788
- if (ourVal !== baseVal && theirVal === baseVal) merged[key] = ours[key];
789
- else if (ourVal !== baseVal && theirVal !== baseVal) merged[key] = ours[key];
790
- }
791
- return merged;
792
- }
793
- function indexById(entries) {
794
- const map = /* @__PURE__ */ new Map();
795
- for (const entry of entries) map.set(entry.ID, entry);
796
- return map;
797
- }
798
- //#endregion
799
- //#region src/core/git/sync.ts
800
- var GitSyncManager = class {
801
- config;
802
- constructor(config) {
803
- this.config = {
804
- ...config,
805
- remote: config.remote || "origin",
806
- retryAttempts: config.retryAttempts || 3,
807
- commitPrefix: config.commitPrefix || "[contentrain]"
808
- };
809
- }
810
- /**
811
- * Create an isolated git worktree for safe operations.
812
- */
813
- async createWorktree() {
814
- const id = randomBytes(4).toString("hex");
815
- const worktreePath = join(tmpdir(), `contentrain-mcp-${id}`);
816
- const git = simpleGit(this.config.repoPath);
817
- try {
818
- await git.fetch(this.config.remote, this.config.branch);
819
- const remoteBranch = `${this.config.remote}/${this.config.branch}`;
820
- const localBranch = `contentrain-work-${id}`;
821
- await git.raw([
822
- "worktree",
823
- "add",
824
- "-b",
825
- localBranch,
826
- worktreePath,
827
- remoteBranch
828
- ]);
829
- return {
830
- path: worktreePath,
831
- branch: localBranch,
832
- id
833
- };
834
- } catch (err) {
835
- throw new GitSyncError(`Failed to create worktree: ${err}`, {
836
- branch: this.config.branch,
837
- worktreePath
838
- });
839
- }
840
- }
841
- /**
842
- * Commit all changes in the worktree.
843
- */
844
- async commit(ctx, message) {
845
- const git = simpleGit(ctx.path);
846
- const fullMessage = `${this.config.commitPrefix} ${message}`;
847
- await git.add("-A");
848
- if ((await git.status()).files.length === 0) return "";
849
- const authorArgs = [];
850
- if (this.config.author) authorArgs.push("--author", `${this.config.author.name} <${this.config.author.email}>`);
851
- return (await git.commit(fullMessage, void 0, Object.fromEntries(authorArgs.length ? [["--author", `${this.config.author.name} <${this.config.author.email}>`]] : []))).commit;
852
- }
853
- /**
854
- * Push with rebase-retry on conflict.
855
- */
856
- async pushWithRetry(ctx) {
857
- const git = simpleGit(ctx.path);
858
- const maxAttempts = this.config.retryAttempts;
859
- for (let attempt = 1; attempt <= maxAttempts; attempt++) try {
860
- await git.push(this.config.remote, `${ctx.branch}:${this.config.branch}`);
861
- return;
862
- } catch {
863
- if (attempt === maxAttempts) throw new PushRejectedError(true);
864
- try {
865
- await this.resolveAndRetry(ctx);
866
- } catch (mergeErr) {
867
- if (attempt === maxAttempts) throw new GitSyncError(`Failed to resolve conflicts after ${maxAttempts} attempts`, { originalError: String(mergeErr) });
868
- }
869
- }
870
- }
871
- /**
872
- * Cleanup: remove the worktree.
873
- */
874
- async cleanup(ctx) {
875
- try {
876
- await simpleGit(this.config.repoPath).raw([
877
- "worktree",
878
- "remove",
879
- ctx.path,
880
- "--force"
881
- ]);
882
- } catch {
883
- await removeDir(ctx.path);
884
- }
885
- try {
886
- await simpleGit(this.config.repoPath).raw([
887
- "branch",
888
- "-D",
889
- ctx.branch
890
- ]);
891
- } catch {}
892
- }
893
- /**
894
- * Run a full transaction: worktree → execute → commit → push → cleanup.
895
- */
896
- async transaction(fn, message) {
897
- const ctx = await this.createWorktree();
898
- try {
899
- const result = await fn(ctx.path);
900
- if (await this.commit(ctx, message)) await this.pushWithRetry(ctx);
901
- return result;
902
- } finally {
903
- await this.cleanup(ctx);
904
- }
905
- }
906
- /**
907
- * Run a staging transaction: worktree → execute → commit → push to staging branch → cleanup.
908
- * Changes are pushed to a separate branch instead of the target branch.
909
- */
910
- async stagingTransaction(fn, message, stagingBranch) {
911
- const ctx = await this.createWorktree();
912
- try {
913
- const result = await fn(ctx.path);
914
- const commitHash = await this.commit(ctx, message);
915
- if (commitHash) {
916
- const git = simpleGit(ctx.path);
917
- try {
918
- await git.push(this.config.remote, `${ctx.branch}:${stagingBranch}`);
919
- } catch (err) {
920
- throw new GitSyncError(`Failed to push to staging branch "${stagingBranch}"`, {
921
- branch: stagingBranch,
922
- originalError: String(err)
923
- });
924
- }
925
- }
926
- return {
927
- result,
928
- hasChanges: !!commitHash
929
- };
930
- } finally {
931
- await this.cleanup(ctx);
932
- }
933
- }
934
- /**
935
- * Merge a remote staging branch into the target branch.
936
- * Tries fast-forward first, falls back to merge commit.
937
- */
938
- async mergeStagingToTarget(stagingBranch) {
939
- const git = simpleGit(this.config.repoPath);
940
- const remote = this.config.remote;
941
- await git.fetch(remote, stagingBranch);
942
- try {
943
- await git.raw([
944
- "rev-parse",
945
- "--verify",
946
- `${remote}/${stagingBranch}`
947
- ]);
948
- } catch {
949
- throw new GitSyncError(`Staging branch "${stagingBranch}" not found on remote "${remote}"`, { branch: stagingBranch });
950
- }
951
- await git.fetch(remote, this.config.branch);
952
- const ctx = await this.createWorktree();
953
- try {
954
- const wtGit = simpleGit(ctx.path);
955
- try {
956
- await wtGit.merge([`${remote}/${stagingBranch}`, "--ff-only"]);
957
- } catch {
958
- await wtGit.merge([
959
- `${remote}/${stagingBranch}`,
960
- "-m",
961
- `${this.config.commitPrefix} apply doctor fix from ${stagingBranch}`
962
- ]);
963
- }
964
- await this.pushWithRetry(ctx);
965
- try {
966
- await git.push(remote, `:${stagingBranch}`);
967
- } catch {}
968
- return (await wtGit.log({ maxCount: 1 })).latest?.hash || "";
969
- } finally {
970
- await this.cleanup(ctx);
971
- }
972
- }
973
- /**
974
- * Delete a remote staging branch without merging.
975
- */
976
- async deleteStagingBranch(stagingBranch) {
977
- const git = simpleGit(this.config.repoPath);
978
- try {
979
- await git.push(this.config.remote, `:${stagingBranch}`);
980
- } catch (err) {
981
- throw new GitSyncError(`Failed to delete staging branch "${stagingBranch}"`, {
982
- branch: stagingBranch,
983
- originalError: String(err)
984
- });
985
- }
986
- }
987
- /**
988
- * Resolve push conflicts via structural JSON merge.
989
- */
990
- async resolveAndRetry(ctx) {
991
- const git = simpleGit(ctx.path);
992
- const remoteBranch = `${this.config.remote}/${this.config.branch}`;
993
- await git.fetch(this.config.remote, this.config.branch);
994
- const baseCommit = (await git.raw([
995
- "merge-base",
996
- ctx.branch,
997
- remoteBranch
998
- ])).trim();
999
- const changedFiles = (await git.raw([
1000
- "diff",
1001
- "--name-only",
1002
- `${remoteBranch}...${ctx.branch}`
1003
- ])).trim().split("\n").filter((f) => f.endsWith(".json"));
1004
- for (const file of changedFiles) {
1005
- if (!file.includes("contentrain/") && !file.startsWith("contentrain/")) continue;
1006
- try {
1007
- const basePath = join(ctx.path, file);
1008
- const base = await this.readGitVersion(git, baseCommit, file);
1009
- const ours = await readJsonFile(basePath);
1010
- const theirsRaw = await git.raw(["show", `${remoteBranch}:${file}`]);
1011
- await writeJsonFile(basePath, structuralMerge(base, ours, JSON.parse(theirsRaw)));
1012
- } catch {}
1013
- }
1014
- await git.raw([
1015
- "reset",
1016
- "--soft",
1017
- remoteBranch
1018
- ]);
1019
- await git.add("-A");
1020
- if ((await git.status()).files.length > 0) await git.commit(`${this.config.commitPrefix} merge: resolve concurrent changes`);
1021
- }
1022
- async readGitVersion(git, commit, filePath) {
1023
- try {
1024
- const raw = await git.raw(["show", `${commit}:${filePath}`]);
1025
- return JSON.parse(raw);
1026
- } catch {
1027
- return [];
1028
- }
1029
- }
1030
- };
1031
- //#endregion
1032
- //#region src/core/writer.ts
1033
- /**
1034
- * Main orchestrator — coordinates all managers and git sync.
1035
- *
1036
- * Three modes:
1037
- * 1. Read mode: reads from repoPath directly (no git sync)
1038
- * 2. Transaction mode: creates a worktree, applies changes, commits + pushes
1039
- * 3. Dry-run mode: copies contentrain dir to temp, applies changes there, then discards
1040
- */
1041
- var ContentrainWriter = class {
1042
- config;
1043
- contentrainDir;
1044
- model;
1045
- content;
1046
- asset;
1047
- validator;
1048
- gitSync;
1049
- constructor(config) {
1050
- this.config = config;
1051
- this.contentrainDir = config.contentrainDir || "contentrain";
1052
- const basePath = join(config.repoPath, this.contentrainDir);
1053
- this.model = new ModelManager(basePath);
1054
- this.content = new ContentManager(basePath, config.repoPath);
1055
- this.asset = new AssetManager(basePath, config.repoPath, config);
1056
- this.validator = new Validator(basePath);
1057
- this.gitSync = new GitSyncManager(config);
1058
- }
1059
- /**
1060
- * Execute operations in a git-synced transaction.
1061
- * Creates worktree → runs operations → validates → commits → pushes.
1062
- * In dry-run mode, copies to a temp directory and discards after execution.
1063
- */
1064
- async transaction(fn, options) {
1065
- if (this.config.dryRun) return this.dryRunTransaction(fn);
1066
- return this.gitSync.transaction(async (worktreePath) => {
1067
- return fn(this.createManagers(worktreePath));
1068
- }, options?.message || "content update");
1069
- }
1070
- /**
1071
- * Execute operations and push to a staging branch instead of the target branch.
1072
- * Returns the result and whether any changes were pushed.
1073
- */
1074
- async stagingTransaction(fn, options) {
1075
- if (this.config.dryRun) return {
1076
- result: await this.dryRunTransaction(fn),
1077
- hasChanges: false
1078
- };
1079
- return this.gitSync.stagingTransaction(async (worktreePath) => {
1080
- return fn(this.createManagers(worktreePath));
1081
- }, options.message, options.stagingBranch);
1082
- }
1083
- /**
1084
- * Merge a staging branch into the target branch.
1085
- */
1086
- async applyStagingBranch(stagingBranch) {
1087
- return this.gitSync.mergeStagingToTarget(stagingBranch);
1088
- }
1089
- /**
1090
- * Delete a staging branch without merging.
1091
- */
1092
- async discardStagingBranch(stagingBranch) {
1093
- return this.gitSync.deleteStagingBranch(stagingBranch);
1094
- }
1095
- /**
1096
- * Dry-run: copy contentrain dir to temp, run operations there, then discard.
1097
- * The original repository is never modified.
1098
- */
1099
- async dryRunTransaction(fn) {
1100
- const tempRepoPath = await mkdtemp(join(tmpdir(), "contentrain-dry-"));
1101
- const tempContentrainDir = join(tempRepoPath, this.contentrainDir);
1102
- const sourceContentrainDir = join(this.config.repoPath, this.contentrainDir);
1103
- try {
1104
- await cp(sourceContentrainDir, tempContentrainDir, { recursive: true });
1105
- return await fn(this.createManagers(tempRepoPath));
1106
- } finally {
1107
- await removeDir(tempRepoPath);
1108
- }
1109
- }
1110
- /**
1111
- * Create managers for a specific repo path (used in transactions).
1112
- */
1113
- createManagers(repoPath) {
1114
- const basePath = join(repoPath, this.contentrainDir);
1115
- return {
1116
- basePath,
1117
- repoPath,
1118
- model: new ModelManager(basePath),
1119
- content: new ContentManager(basePath, repoPath),
1120
- asset: new AssetManager(basePath, repoPath, this.config),
1121
- validator: new Validator(basePath)
1122
- };
1123
- }
1124
- };
1125
- //#endregion
1126
- //#region src/tools/model.ts
1127
- function registerModelTools(server, writer) {
1128
- server.registerTool("contentrain_list_models", { description: "List all Contentrain models with their metadata (type, localization, etc.)" }, async () => {
1129
- try {
1130
- const models = await writer.model.list();
1131
- return { content: [{
1132
- type: "text",
1133
- text: JSON.stringify(models, null, 2)
1134
- }] };
1135
- } catch (err) {
1136
- return {
1137
- content: [{
1138
- type: "text",
1139
- text: `Error: ${err}`
1140
- }],
1141
- isError: true
1142
- };
1143
- }
1144
- });
1145
- server.registerTool("contentrain_describe_model", {
1146
- description: "Get detailed schema for a model including all field definitions, types, validations, and options. Call this BEFORE creating or updating content to understand required fields.",
1147
- inputSchema: { modelId: z.string().describe("The model ID to describe") }
1148
- }, async ({ modelId }) => {
1149
- try {
1150
- const desc = await writer.model.describe(modelId);
1151
- return { content: [{
1152
- type: "text",
1153
- text: JSON.stringify(desc, null, 2)
1154
- }] };
1155
- } catch (err) {
1156
- return {
1157
- content: [{
1158
- type: "text",
1159
- text: `Error: ${err}`
1160
- }],
1161
- isError: true
1162
- };
1163
- }
1164
- });
1165
- server.registerTool("contentrain_create_model", {
1166
- description: "Create a new Contentrain model with field definitions. System fields (ID, createdAt, updatedAt, status) are auto-added. For MD/MDX models, slug and content fields are also auto-added.",
1167
- inputSchema: {
1168
- name: z.string().describe("Display name of the model"),
1169
- modelId: z.string().regex(/^[a-z0-9-]+$/).describe("Unique model ID in kebab-case"),
1170
- type: z.enum([
1171
- "JSON",
1172
- "MD",
1173
- "MDX"
1174
- ]).describe("Content type"),
1175
- localization: z.boolean().describe("Whether the model supports multiple languages"),
1176
- description: z.string().optional().describe("Model description"),
1177
- path: z.string().optional().describe("Base path for MD/MDX files"),
1178
- fields: z.array(z.object({
1179
- name: z.string(),
1180
- fieldId: z.string(),
1181
- componentId: z.string(),
1182
- fieldType: z.string(),
1183
- options: z.record(z.unknown()).optional(),
1184
- validations: z.record(z.unknown()).optional()
1185
- })).optional().describe("Custom field definitions (system fields are auto-added)")
1186
- }
1187
- }, async (params) => {
1188
- try {
1189
- const result = await writer.transaction(async (tx) => {
1190
- return tx.model.create({
1191
- name: params.name,
1192
- modelId: params.modelId,
1193
- type: params.type,
1194
- localization: params.localization,
1195
- description: params.description,
1196
- path: params.path,
1197
- fields: params.fields
1198
- });
1199
- }, { message: `create model: ${params.modelId}` });
1200
- return { content: [{
1201
- type: "text",
1202
- text: JSON.stringify({
1203
- success: true,
1204
- model: result
1205
- }, null, 2)
1206
- }] };
1207
- } catch (err) {
1208
- return {
1209
- content: [{
1210
- type: "text",
1211
- text: `Error: ${err}`
1212
- }],
1213
- isError: true
1214
- };
1215
- }
1216
- });
1217
- server.registerTool("contentrain_add_field", {
1218
- description: "Add a new field to an existing model.",
1219
- inputSchema: {
1220
- modelId: z.string().describe("Target model ID"),
1221
- name: z.string().describe("Display name of the field"),
1222
- fieldId: z.string().describe("Unique field identifier"),
1223
- componentId: z.string().describe("UI component (single-line-text, integer, one-to-one, media, etc.)"),
1224
- fieldType: z.string().describe("Data type (string, number, boolean, relation, media, date, array)"),
1225
- options: z.record(z.unknown()).optional().describe("Field options (title-field, default-value, reference, etc.)"),
1226
- validations: z.record(z.unknown()).optional().describe("Field validations (required-field, unique-field)")
1227
- }
1228
- }, async (params) => {
1229
- try {
1230
- const result = await writer.transaction(async (tx) => {
1231
- return tx.model.addField(params.modelId, {
1232
- name: params.name,
1233
- fieldId: params.fieldId,
1234
- componentId: params.componentId,
1235
- fieldType: params.fieldType,
1236
- options: params.options || {},
1237
- validations: params.validations || {}
1238
- });
1239
- }, { message: `add field ${params.fieldId} to ${params.modelId}` });
1240
- return { content: [{
1241
- type: "text",
1242
- text: JSON.stringify({
1243
- success: true,
1244
- field: result
1245
- }, null, 2)
1246
- }] };
1247
- } catch (err) {
1248
- return {
1249
- content: [{
1250
- type: "text",
1251
- text: `Error: ${err}`
1252
- }],
1253
- isError: true
1254
- };
1255
- }
1256
- });
1257
- server.registerTool("contentrain_delete_model", {
1258
- description: "Delete a model and all its content. WARNING: This is destructive and cannot be undone.",
1259
- annotations: { destructiveHint: true },
1260
- inputSchema: { modelId: z.string().describe("Model ID to delete") }
1261
- }, async ({ modelId }) => {
1262
- try {
1263
- await writer.transaction(async (tx) => {
1264
- await tx.model.delete(modelId);
1265
- }, { message: `delete model: ${modelId}` });
1266
- return { content: [{
1267
- type: "text",
1268
- text: JSON.stringify({
1269
- success: true,
1270
- deleted: modelId
1271
- })
1272
- }] };
1273
- } catch (err) {
1274
- return {
1275
- content: [{
1276
- type: "text",
1277
- text: `Error: ${err}`
1278
- }],
1279
- isError: true
1280
- };
1281
- }
1282
- });
1283
- }
1284
- //#endregion
1285
- //#region src/tools/content.ts
1286
- function registerContentTools(server, writer) {
1287
- server.registerTool("contentrain_list_content", {
1288
- description: "List all content entries for a model. For localized models, specify a locale.",
1289
- annotations: { readOnlyHint: true },
1290
- inputSchema: {
1291
- modelId: z.string().describe("Model ID to list content from"),
1292
- locale: z.string().optional().describe("Locale code (e.g., \"en\", \"tr\") for localized models")
1293
- }
1294
- }, async ({ modelId, locale }) => {
1295
- try {
1296
- const entries = await writer.content.list(modelId, locale);
1297
- return { content: [{
1298
- type: "text",
1299
- text: JSON.stringify({
1300
- total: entries.length,
1301
- entries
1302
- }, null, 2)
1303
- }] };
1304
- } catch (err) {
1305
- return {
1306
- content: [{
1307
- type: "text",
1308
- text: `Error: ${err}`
1309
- }],
1310
- isError: true
1311
- };
1312
- }
1313
- });
1314
- server.registerTool("contentrain_get_content", {
1315
- description: "Get a single content entry by ID.",
1316
- annotations: { readOnlyHint: true },
1317
- inputSchema: {
1318
- modelId: z.string().describe("Model ID"),
1319
- entryId: z.string().describe("Entry ID (12-char hex)"),
1320
- locale: z.string().optional().describe("Locale code for localized models")
1321
- }
1322
- }, async ({ modelId, entryId, locale }) => {
1323
- try {
1324
- const entry = await writer.content.get(modelId, entryId, locale);
1325
- return { content: [{
1326
- type: "text",
1327
- text: JSON.stringify(entry, null, 2)
1328
- }] };
1329
- } catch (err) {
1330
- return {
1331
- content: [{
1332
- type: "text",
1333
- text: `Error: ${err}`
1334
- }],
1335
- isError: true
1336
- };
1337
- }
1338
- });
1339
- server.registerTool("contentrain_create_content", {
1340
- description: "Create a new content entry. System fields (ID, createdAt, updatedAt, status) are auto-generated. For localized models, specify locale to create in that language. Call contentrain_describe_model first to know required fields.",
1341
- inputSchema: {
1342
- modelId: z.string().describe("Target model ID"),
1343
- data: z.record(z.unknown()).describe("Content data object with field values matching model schema"),
1344
- locale: z.string().optional().describe("Locale code (e.g., \"en\") — required for localized models"),
1345
- status: z.enum(["draft", "publish"]).optional().describe("Entry status (default: \"draft\")"),
1346
- content: z.string().optional().describe("Markdown body content (only for MD/MDX models)")
1347
- }
1348
- }, async (params) => {
1349
- try {
1350
- const result = await writer.transaction(async (tx) => {
1351
- const validation = await tx.validator.validate(params.modelId, params.data);
1352
- if (!validation.valid) throw new Error(`Validation failed:\n${validation.errors.map((e) => ` - ${e.fieldId}: ${e.message}`).join("\n")}`);
1353
- return tx.content.create(params.modelId, params.data, {
1354
- locale: params.locale,
1355
- status: params.status,
1356
- content: params.content
1357
- });
1358
- }, { message: `create ${params.modelId} entry` });
1359
- return { content: [{
1360
- type: "text",
1361
- text: JSON.stringify({
1362
- success: true,
1363
- id: result
1364
- }, null, 2)
1365
- }] };
1366
- } catch (err) {
1367
- return {
1368
- content: [{
1369
- type: "text",
1370
- text: `Error: ${err}`
1371
- }],
1372
- isError: true
1373
- };
1374
- }
1375
- });
1376
- server.registerTool("contentrain_update_content", {
1377
- description: "Update an existing content entry. Only provide fields you want to change. updatedAt is auto-set.",
1378
- inputSchema: {
1379
- modelId: z.string().describe("Model ID"),
1380
- entryId: z.string().describe("Entry ID to update (12-char hex)"),
1381
- data: z.record(z.unknown()).describe("Fields to update"),
1382
- locale: z.string().optional().describe("Locale code for localized models"),
1383
- content: z.string().optional().describe("Updated markdown body (only for MD/MDX models)")
1384
- }
1385
- }, async (params) => {
1386
- try {
1387
- const result = await writer.transaction(async (tx) => {
1388
- const validation = await tx.validator.validate(params.modelId, params.data, { isUpdate: true });
1389
- if (!validation.valid) throw new Error(`Validation failed:\n${validation.errors.map((e) => ` - ${e.fieldId}: ${e.message}`).join("\n")}`);
1390
- return tx.content.update(params.modelId, params.entryId, params.data, {
1391
- locale: params.locale,
1392
- content: params.content
1393
- });
1394
- }, { message: `update ${params.modelId}/${params.entryId}` });
1395
- return { content: [{
1396
- type: "text",
1397
- text: JSON.stringify({
1398
- success: true,
1399
- entry: result
1400
- }, null, 2)
1401
- }] };
1402
- } catch (err) {
1403
- return {
1404
- content: [{
1405
- type: "text",
1406
- text: `Error: ${err}`
1407
- }],
1408
- isError: true
1409
- };
1410
- }
1411
- });
1412
- server.registerTool("contentrain_delete_content", {
1413
- description: "Delete a content entry by ID. For localized models, removes from all locale files. For MD/MDX, also deletes markdown files.",
1414
- annotations: { destructiveHint: true },
1415
- inputSchema: {
1416
- modelId: z.string().describe("Model ID"),
1417
- entryId: z.string().describe("Entry ID to delete (12-char hex)")
1418
- }
1419
- }, async ({ modelId, entryId }) => {
1420
- try {
1421
- await writer.transaction(async (tx) => {
1422
- await tx.content.delete(modelId, entryId);
1423
- }, { message: `delete ${modelId}/${entryId}` });
1424
- return { content: [{
1425
- type: "text",
1426
- text: JSON.stringify({
1427
- success: true,
1428
- deleted: entryId
1429
- })
1430
- }] };
1431
- } catch (err) {
1432
- return {
1433
- content: [{
1434
- type: "text",
1435
- text: `Error: ${err}`
1436
- }],
1437
- isError: true
1438
- };
1439
- }
1440
- });
1441
- server.registerTool("contentrain_validate", {
1442
- description: "Validate content data against model schema without writing. Use for dry-run checks.",
1443
- annotations: { readOnlyHint: true },
1444
- inputSchema: {
1445
- modelId: z.string().describe("Model ID to validate against"),
1446
- data: z.record(z.unknown()).describe("Content data to validate")
1447
- }
1448
- }, async ({ modelId, data }) => {
1449
- try {
1450
- const result = await writer.validator.validate(modelId, data);
1451
- return { content: [{
1452
- type: "text",
1453
- text: JSON.stringify(result, null, 2)
1454
- }] };
1455
- } catch (err) {
1456
- return {
1457
- content: [{
1458
- type: "text",
1459
- text: `Error: ${err}`
1460
- }],
1461
- isError: true
1462
- };
1463
- }
1464
- });
1465
- }
1466
- //#endregion
1467
- //#region src/tools/asset.ts
1468
- function registerAssetTools(server, writer) {
1469
- server.registerTool("contentrain_list_assets", {
1470
- description: "List all registered assets in assets.json.",
1471
- annotations: { readOnlyHint: true }
1472
- }, async () => {
1473
- try {
1474
- const assets = await writer.asset.list();
1475
- return { content: [{
1476
- type: "text",
1477
- text: JSON.stringify({
1478
- total: assets.length,
1479
- assets
1480
- }, null, 2)
1481
- }] };
1482
- } catch (err) {
1483
- return {
1484
- content: [{
1485
- type: "text",
1486
- text: `Error: ${err}`
1487
- }],
1488
- isError: true
1489
- };
1490
- }
1491
- });
1492
- server.registerTool("contentrain_register_asset", {
1493
- description: "Register a new asset in assets.json. The file must already exist at the specified path in the repository.",
1494
- inputSchema: {
1495
- path: z.string().describe("Relative path to the asset file (e.g., \"contentrain/static/hero.png\")"),
1496
- mimetype: z.string().describe("MIME type (e.g., \"image/png\", \"image/jpeg\", \"application/pdf\")"),
1497
- alt: z.string().optional().describe("Alt text for the asset")
1498
- }
1499
- }, async (params) => {
1500
- try {
1501
- const result = await writer.transaction(async (tx) => {
1502
- return tx.asset.register({
1503
- path: params.path,
1504
- mimetype: params.mimetype,
1505
- alt: params.alt
1506
- });
1507
- }, { message: `register asset: ${params.path}` });
1508
- return { content: [{
1509
- type: "text",
1510
- text: JSON.stringify({
1511
- success: true,
1512
- asset: result
1513
- }, null, 2)
1514
- }] };
1515
- } catch (err) {
1516
- return {
1517
- content: [{
1518
- type: "text",
1519
- text: `Error: ${err}`
1520
- }],
1521
- isError: true
1522
- };
1523
- }
1524
- });
1525
- server.registerTool("contentrain_deregister_asset", {
1526
- description: "Remove an asset from assets.json registry. Does NOT delete the physical file.",
1527
- annotations: { destructiveHint: true },
1528
- inputSchema: { path: z.string().describe("Asset path to deregister") }
1529
- }, async ({ path }) => {
1530
- try {
1531
- await writer.transaction(async (tx) => {
1532
- await tx.asset.deregister(path);
1533
- }, { message: `deregister asset: ${path}` });
1534
- return { content: [{
1535
- type: "text",
1536
- text: JSON.stringify({
1537
- success: true,
1538
- deregistered: path
1539
- })
1540
- }] };
1541
- } catch (err) {
1542
- return {
1543
- content: [{
1544
- type: "text",
1545
- text: `Error: ${err}`
1546
- }],
1547
- isError: true
1548
- };
1549
- }
1550
- });
1551
- }
1552
- //#endregion
1553
- //#region src/core/doctor.ts
1554
- var Doctor = class {
1555
- issues = [];
1556
- fixed = 0;
1557
- totalEntries = 0;
1558
- scannedEntries = 0;
1559
- idRemap = /* @__PURE__ */ new Map();
1560
- contentFiles = [];
1561
- fieldsByModel = /* @__PURE__ */ new Map();
1562
- constructor(basePath, repoPath) {
1563
- this.basePath = basePath;
1564
- this.repoPath = repoPath;
1565
- }
1566
- async diagnose(options = {}) {
1567
- this.issues = [];
1568
- this.fixed = 0;
1569
- this.totalEntries = 0;
1570
- this.scannedEntries = 0;
1571
- this.idRemap = /* @__PURE__ */ new Map();
1572
- this.contentFiles = [];
1573
- this.fieldsByModel = /* @__PURE__ */ new Map();
1574
- const fix = options.fix ?? false;
1575
- const models = await this.checkMetadata();
1576
- if (!models) return this.buildResult(0);
1577
- const targetModels = options.modelId ? models.filter((m) => m.modelId === options.modelId) : models;
1578
- if (options.modelId && targetModels.length === 0) {
1579
- this.addIssue("error", "structure", `Model "${options.modelId}" not found in metadata.json`);
1580
- return this.buildResult(models.length);
1581
- }
1582
- await this.checkFieldFiles(models, targetModels);
1583
- const assets = await this.loadAssets();
1584
- await this.loadAllContentFiles(fix ? models : targetModels);
1585
- for (const model of models) try {
1586
- const fields = await readJsonFile(join(this.basePath, "models", `${model.modelId}.json`));
1587
- this.fieldsByModel.set(model.modelId, fields);
1588
- } catch {}
1589
- for (const cf of this.contentFiles) if (targetModels.some((m) => m.modelId === cf.modelId)) this.scannedEntries += cf.entries.length;
1590
- for (const model of targetModels) {
1591
- this.checkModelStructure(model);
1592
- this.checkModelContent(model, assets, fix);
1593
- if (model.type === "MD" || model.type === "MDX") {
1594
- const modelFiles = this.contentFiles.filter((f) => f.modelId === model.modelId);
1595
- await this.checkMarkdownSync(model, modelFiles);
1596
- }
1597
- }
1598
- if (!options.modelId) await this.checkOrphanDirectories(models);
1599
- if (fix && this.idRemap.size > 0) this.propagateIdChanges();
1600
- if (fix) await this.writeDirtyFiles();
1601
- return this.buildResult(targetModels.length);
1602
- }
1603
- async loadAllContentFiles(models) {
1604
- for (const model of models) {
1605
- const contentDir = join(this.basePath, model.modelId);
1606
- const fileNames = await listJsonFiles(contentDir);
1607
- for (const fileName of fileNames) {
1608
- const filePath = join(contentDir, `${fileName}.json`);
1609
- let entries;
1610
- try {
1611
- const raw = await readFile(filePath, "utf-8");
1612
- entries = JSON.parse(raw);
1613
- if (!Array.isArray(entries)) {
1614
- this.addIssue("error", "structure", "Content file is not an array", model.modelId, void 0, void 0, `${model.modelId}/${fileName}.json`);
1615
- continue;
1616
- }
1617
- } catch {
1618
- this.addIssue("error", "structure", "Invalid JSON in content file", model.modelId, void 0, void 0, `${model.modelId}/${fileName}.json`);
1619
- continue;
1620
- }
1621
- this.totalEntries += entries.length;
1622
- this.contentFiles.push({
1623
- modelId: model.modelId,
1624
- fileName,
1625
- filePath,
1626
- entries,
1627
- dirty: false
1628
- });
1629
- }
1630
- }
1631
- }
1632
- async checkMetadata() {
1633
- const metadataPath = join(this.basePath, "models", "metadata.json");
1634
- try {
1635
- const raw = await readFile(metadataPath, "utf-8");
1636
- try {
1637
- const models = JSON.parse(raw);
1638
- if (!Array.isArray(models)) {
1639
- this.addIssue("error", "structure", "metadata.json is not an array", void 0, void 0, void 0, "models/metadata.json");
1640
- return null;
1641
- }
1642
- const seen = /* @__PURE__ */ new Map();
1643
- for (const m of models) {
1644
- const count = (seen.get(m.modelId) ?? 0) + 1;
1645
- seen.set(m.modelId, count);
1646
- }
1647
- for (const [modelId, count] of seen) if (count > 1) this.addIssue("error", "structure", `Duplicate modelId "${modelId}" in metadata.json (${count} times)`, modelId, void 0, void 0, "models/metadata.json");
1648
- return models;
1649
- } catch {
1650
- this.addIssue("error", "structure", "metadata.json contains invalid JSON", void 0, void 0, void 0, "models/metadata.json");
1651
- return null;
1652
- }
1653
- } catch {
1654
- this.addIssue("error", "structure", "metadata.json not found — is this a Contentrain project?", void 0, void 0, void 0, "models/metadata.json");
1655
- return null;
1656
- }
1657
- }
1658
- async checkFieldFiles(allModels, targetModels) {
1659
- for (const model of targetModels) if (!await fileExists(join(this.basePath, "models", `${model.modelId}.json`))) this.addIssue("error", "structure", `Field definition file missing for model "${model.modelId}"`, model.modelId, void 0, void 0, `models/${model.modelId}.json`);
1660
- const metaIds = new Set(allModels.map((m) => m.modelId));
1661
- const fieldFiles = await listJsonFiles(join(this.basePath, "models"));
1662
- for (const file of fieldFiles) {
1663
- if (file === "metadata") continue;
1664
- if (!metaIds.has(file)) this.addIssue("warning", "structure", `Field definition file "${file}.json" has no matching model in metadata.json`, file, void 0, void 0, `models/${file}.json`);
1665
- }
1666
- }
1667
- async checkOrphanDirectories(models) {
1668
- const modelIds = new Set(models.map((m) => m.modelId));
1669
- const { readdir, stat } = await import("node:fs/promises");
1670
- try {
1671
- const entries = await readdir(this.basePath);
1672
- for (const entry of entries) {
1673
- if (entry === "models" || entry === "assets.json" || entry === "static" || entry.startsWith(".")) continue;
1674
- if ((await stat(join(this.basePath, entry))).isDirectory() && !modelIds.has(entry)) this.addIssue("warning", "structure", `Content directory "${entry}/" has no matching model in metadata.json`, entry);
1675
- }
1676
- } catch {}
1677
- }
1678
- checkModelStructure(model) {
1679
- const fields = this.fieldsByModel.get(model.modelId);
1680
- if (!fields) return;
1681
- for (const sysId of [
1682
- "createdAt",
1683
- "updatedAt",
1684
- "ID",
1685
- "status"
1686
- ]) if (!fields.some((f) => f.fieldId === sysId && f.system)) this.addIssue("error", "schema", `Missing system field "${sysId}" in field definitions`, model.modelId, void 0, void 0, `models/${model.modelId}.json`);
1687
- if (model.type === "MD" || model.type === "MDX") {
1688
- if (!fields.some((f) => f.fieldId === "slug")) this.addIssue("error", "schema", "MD/MDX model missing required \"slug\" field", model.modelId, void 0, void 0, `models/${model.modelId}.json`);
1689
- if (!fields.some((f) => f.fieldId === "content")) this.addIssue("warning", "schema", "MD/MDX model missing \"content\" field definition", model.modelId, void 0, void 0, `models/${model.modelId}.json`);
1690
- }
1691
- }
1692
- checkModelContent(model, assets, fix) {
1693
- const fields = this.fieldsByModel.get(model.modelId);
1694
- if (!fields) return;
1695
- const modelFiles = this.contentFiles.filter((f) => f.modelId === model.modelId);
1696
- if (modelFiles.length === 0 && !model.localization) {
1697
- this.addIssue("warning", "structure", `No content files found for model "${model.modelId}"`, model.modelId);
1698
- return;
1699
- }
1700
- for (const cf of modelFiles) {
1701
- const fileIdSet = /* @__PURE__ */ new Map();
1702
- for (const entry of cf.entries) {
1703
- const count = (fileIdSet.get(entry.ID) ?? 0) + 1;
1704
- fileIdSet.set(entry.ID, count);
1705
- }
1706
- for (const [id, count] of fileIdSet) if (count > 1) {
1707
- this.addIssue("error", "id", `Duplicate ID "${id}" appears ${count} times in same file`, model.modelId, id, void 0, `${model.modelId}/${cf.fileName}.json`, fix ? "Will regenerate duplicate IDs and update relations" : void 0);
1708
- if (fix) this.fixDuplicateIds(cf, id);
1709
- }
1710
- for (const entry of cf.entries) this.checkEntry(model, fields, entry, assets, `${model.modelId}/${cf.fileName}.json`, fix, cf);
1711
- this.checkUniqueConstraints(model, fields, cf.entries, `${model.modelId}/${cf.fileName}.json`);
1712
- }
1713
- if (model.localization && modelFiles.length > 1) this.checkLocaleIdConsistency(model, modelFiles);
1714
- }
1715
- checkEntry(model, fields, entry, assets, file, fix, cf) {
1716
- const entryId = entry.ID;
1717
- if (!entryId || typeof entryId !== "string") {
1718
- this.addIssue("error", "id", "Entry missing ID field", model.modelId, void 0, "ID", file);
1719
- return;
1720
- }
1721
- if (!isValidId(entryId)) {
1722
- const existingNewId = this.idRemap.get(entryId);
1723
- const newId = fix ? existingNewId || generateId() : void 0;
1724
- this.addIssue("error", "id", `Invalid ID format "${entryId}" — must be 12-char lowercase hex`, model.modelId, entryId, "ID", file, fix ? `Regenerated to "${newId}" and updating all relations` : void 0);
1725
- if (fix && newId) {
1726
- this.idRemap.set(entryId, newId);
1727
- entry.ID = newId;
1728
- cf.dirty = true;
1729
- if (!existingNewId) this.fixed++;
1730
- }
1731
- }
1732
- if (!entry.createdAt) {
1733
- this.addIssue("error", "schema", "Missing createdAt", model.modelId, entryId, "createdAt", file, fix ? "Will set to current timestamp" : void 0);
1734
- if (fix) {
1735
- entry.createdAt = (/* @__PURE__ */ new Date()).toISOString();
1736
- cf.dirty = true;
1737
- this.fixed++;
1738
- }
1739
- }
1740
- if (!entry.updatedAt) {
1741
- this.addIssue("error", "schema", "Missing updatedAt", model.modelId, entryId, "updatedAt", file, fix ? "Will set to current timestamp" : void 0);
1742
- if (fix) {
1743
- entry.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1744
- cf.dirty = true;
1745
- this.fixed++;
1746
- }
1747
- }
1748
- if (!entry.status) {
1749
- this.addIssue("error", "schema", "Missing status", model.modelId, entryId, "status", file, fix ? "Will set to \"draft\"" : void 0);
1750
- if (fix) {
1751
- entry.status = "draft";
1752
- cf.dirty = true;
1753
- this.fixed++;
1754
- }
1755
- } else if (![
1756
- "draft",
1757
- "publish",
1758
- "changed"
1759
- ].includes(entry.status)) {
1760
- this.addIssue("error", "schema", `Invalid status "${entry.status}" — must be draft, publish, or changed`, model.modelId, entryId, "status", file, fix ? "Will set to \"draft\"" : void 0);
1761
- if (fix) {
1762
- entry.status = "draft";
1763
- cf.dirty = true;
1764
- this.fixed++;
1765
- }
1766
- }
1767
- if (entry.createdAt && isNaN(Date.parse(entry.createdAt))) this.addIssue("error", "schema", `Invalid createdAt date format "${entry.createdAt}"`, model.modelId, entryId, "createdAt", file);
1768
- if (entry.updatedAt && isNaN(Date.parse(entry.updatedAt))) this.addIssue("error", "schema", `Invalid updatedAt date format "${entry.updatedAt}"`, model.modelId, entryId, "updatedAt", file);
1769
- const nonSystemFields = fields.filter((f) => !f.system);
1770
- for (const field of nonSystemFields) {
1771
- const value = entry[field.fieldId];
1772
- if (field.validations?.["required-field"]?.value) {
1773
- if (value === void 0 || value === null || value === "") {
1774
- if (field.fieldId === "content" && (model.type === "MD" || model.type === "MDX")) continue;
1775
- this.addIssue("error", "schema", `Required field "${field.fieldId}" is empty`, model.modelId, entryId, field.fieldId, file);
1776
- continue;
1777
- }
1778
- }
1779
- if (value === void 0 || value === null) continue;
1780
- const typeError = validateFieldValue(value, getExpectedType(field.componentId, field.fieldType), field.fieldId);
1781
- if (typeError) {
1782
- this.addIssue("error", "schema", `Type mismatch: ${typeError}`, model.modelId, entryId, field.fieldId, file);
1783
- continue;
1784
- }
1785
- if (field.componentId === "integer") {
1786
- const intError = validateInteger(value, field.fieldId);
1787
- if (intError) this.addIssue("error", "schema", intError, model.modelId, entryId, field.fieldId, file);
1788
- }
1789
- if (field.componentId === "one-to-one" && typeof value === "string") this.checkRelation(model.modelId, entryId, field, value, file);
1790
- if (field.componentId === "one-to-many" && Array.isArray(value)) for (const refId of value) if (typeof refId === "string") this.checkRelation(model.modelId, entryId, field, refId, file);
1791
- else this.addIssue("error", "relation", "one-to-many array contains non-string value", model.modelId, entryId, field.fieldId, file);
1792
- if ((field.componentId === "one-to-one" || field.componentId === "one-to-many") && !field.options?.reference?.form?.reference?.value) this.addIssue("warning", "relation", `Relation field "${field.fieldId}" missing options.reference.form.reference.value`, model.modelId, void 0, field.fieldId, `models/${model.modelId}.json`);
1793
- if (field.componentId === "media" && typeof value === "string" && value !== "") {
1794
- if (!assets.some((a) => a.path === value)) this.addIssue("error", "asset", `Media path "${value}" not found in assets.json`, model.modelId, entryId, field.fieldId, file);
1795
- }
1796
- }
1797
- }
1798
- checkRelation(modelId, entryId, field, refId, file) {
1799
- const refModelId = field.options?.reference?.form?.reference?.value;
1800
- if (!refModelId) return;
1801
- if (!isValidId(refId)) {
1802
- this.addIssue("error", "relation", `Relation references invalid ID "${refId}"`, modelId, entryId, field.fieldId, file);
1803
- return;
1804
- }
1805
- if (!this.contentFiles.filter((f) => f.modelId === refModelId).some((rf) => rf.entries.some((e) => e.ID === refId))) this.addIssue("error", "relation", `Referenced entry "${refId}" not found in model "${refModelId}"`, modelId, entryId, field.fieldId, file);
1806
- }
1807
- checkUniqueConstraints(model, fields, entries, file) {
1808
- const uniqueFields = fields.filter((f) => f.validations?.["unique-field"]?.value && !f.system);
1809
- for (const field of uniqueFields) {
1810
- const seen = /* @__PURE__ */ new Map();
1811
- for (const entry of entries) {
1812
- const value = entry[field.fieldId];
1813
- if (value === void 0 || value === null || value === "") continue;
1814
- const key = typeof value === "object" ? JSON.stringify(value) : value;
1815
- const existing = seen.get(key);
1816
- if (existing) this.addIssue("error", "schema", `Unique constraint violated: "${field.fieldId}" value "${value}" duplicated between entries ${existing} and ${entry.ID}`, model.modelId, entry.ID, field.fieldId, file);
1817
- else seen.set(key, entry.ID);
1818
- }
1819
- }
1820
- }
1821
- checkLocaleIdConsistency(model, modelFiles) {
1822
- if (modelFiles.length < 2) return;
1823
- const referenceFile = modelFiles[0];
1824
- const referenceIds = new Set(referenceFile.entries.map((e) => e.ID));
1825
- for (let i = 1; i < modelFiles.length; i++) {
1826
- const otherFile = modelFiles[i];
1827
- const otherIds = new Set(otherFile.entries.map((e) => e.ID));
1828
- for (const id of referenceIds) if (!otherIds.has(id)) this.addIssue("error", "id", `Entry "${id}" exists in ${referenceFile.fileName}.json but missing in ${otherFile.fileName}.json`, model.modelId, id, void 0, `${model.modelId}/${otherFile.fileName}.json`);
1829
- for (const id of otherIds) if (!referenceIds.has(id)) this.addIssue("error", "id", `Entry "${id}" exists in ${otherFile.fileName}.json but missing in ${referenceFile.fileName}.json`, model.modelId, id, void 0, `${model.modelId}/${referenceFile.fileName}.json`);
1830
- }
1831
- }
1832
- async checkMarkdownSync(model, modelFiles) {
1833
- for (const cf of modelFiles) {
1834
- const locale = model.localization && cf.fileName !== model.modelId ? cf.fileName : void 0;
1835
- for (const entry of cf.entries) {
1836
- if (!("slug" in entry) || !entry.slug) {
1837
- this.addIssue("error", "sync", "MD/MDX entry missing \"slug\" field", model.modelId, entry.ID, "slug", `${model.modelId}/${cf.fileName}.json`);
1838
- continue;
1839
- }
1840
- const mdEntry = entry;
1841
- const ext = model.type === "MDX" ? ".mdx" : ".md";
1842
- const baseMdPath = mdEntry.path || model.path || "";
1843
- const mdPath = locale ? join(this.repoPath, baseMdPath, locale, `${mdEntry.slug}${ext}`) : join(this.repoPath, baseMdPath, `${mdEntry.slug}${ext}`);
1844
- if (!await fileExists(mdPath)) this.addIssue("error", "sync", `Markdown file missing for entry "${mdEntry.slug}"`, model.modelId, entry.ID, void 0, mdPath.replace(this.repoPath + "/", ""));
1845
- if ("content" in entry && entry.content && typeof entry.content === "string" && entry.content.length > 0) this.addIssue("warning", "sync", "MD/MDX JSON index should not contain \"content\" body — body belongs in markdown file", model.modelId, entry.ID, "content", `${model.modelId}/${cf.fileName}.json`);
1846
- }
1847
- }
1848
- }
1849
- async loadAssets() {
1850
- try {
1851
- return await readJsonFile(join(this.basePath, "assets.json"));
1852
- } catch {
1853
- this.addIssue("warning", "structure", "assets.json not found or invalid");
1854
- return [];
1855
- }
1856
- }
1857
- /**
1858
- * After all ID fixes are collected in idRemap, propagate changes
1859
- * to ALL relation fields across ALL loaded content files.
1860
- *
1861
- * This is the critical two-way binding: when entry A's ID changes,
1862
- * every relation field in every model that points to A must be updated.
1863
- */
1864
- propagateIdChanges() {
1865
- if (this.idRemap.size === 0) return;
1866
- for (const cf of this.contentFiles) {
1867
- const fields = this.fieldsByModel.get(cf.modelId);
1868
- if (!fields) continue;
1869
- const relationFields = fields.filter((f) => f.componentId === "one-to-one" || f.componentId === "one-to-many");
1870
- if (relationFields.length === 0) continue;
1871
- for (const entry of cf.entries) for (const field of relationFields) {
1872
- const value = entry[field.fieldId];
1873
- if (value === void 0 || value === null) continue;
1874
- if (field.componentId === "one-to-one" && typeof value === "string") {
1875
- const newId = this.idRemap.get(value);
1876
- if (newId) {
1877
- this.addIssue("warning", "relation", `Updated relation "${field.fieldId}": "${value}" → "${newId}"`, cf.modelId, entry.ID, field.fieldId, `${cf.modelId}/${cf.fileName}.json`, "Propagated ID change to relation");
1878
- entry[field.fieldId] = newId;
1879
- cf.dirty = true;
1880
- this.fixed++;
1881
- }
1882
- }
1883
- if (field.componentId === "one-to-many" && Array.isArray(value)) {
1884
- let changed = false;
1885
- const updated = value.map((refId) => {
1886
- const newId = this.idRemap.get(refId);
1887
- if (newId) {
1888
- changed = true;
1889
- return newId;
1890
- }
1891
- return refId;
1892
- });
1893
- if (changed) {
1894
- this.addIssue("warning", "relation", `Updated one-to-many relation "${field.fieldId}": propagated ${this.idRemap.size} ID change(s)`, cf.modelId, entry.ID, field.fieldId, `${cf.modelId}/${cf.fileName}.json`, "Propagated ID changes to relation array");
1895
- entry[field.fieldId] = updated;
1896
- cf.dirty = true;
1897
- this.fixed++;
1898
- }
1899
- }
1900
- }
1901
- }
1902
- }
1903
- fixDuplicateIds(cf, duplicateId) {
1904
- let first = true;
1905
- for (const entry of cf.entries) if (entry.ID === duplicateId) {
1906
- if (first) {
1907
- first = false;
1908
- continue;
1909
- }
1910
- const newId = generateId();
1911
- this.idRemap.set(`${duplicateId}:dup:${newId}`, newId);
1912
- entry.ID = newId;
1913
- cf.dirty = true;
1914
- this.fixed++;
1915
- }
1916
- }
1917
- async writeDirtyFiles() {
1918
- for (const cf of this.contentFiles) if (cf.dirty) await writeJsonFile(cf.filePath, cf.entries);
1919
- }
1920
- addIssue(severity, category, message, modelId, entryId, field, file, fix) {
1921
- this.issues.push({
1922
- severity,
1923
- category,
1924
- modelId,
1925
- entryId,
1926
- field,
1927
- file,
1928
- message,
1929
- fix
1930
- });
1931
- }
1932
- buildResult(modelCount) {
1933
- const unfixedErrors = this.issues.filter((i) => i.severity === "error" && !i.fix).length;
1934
- return {
1935
- summary: {
1936
- total: this.issues.length,
1937
- errors: unfixedErrors,
1938
- warnings: this.issues.filter((i) => i.severity === "warning").length,
1939
- fixed: this.fixed,
1940
- models: modelCount,
1941
- entries: this.scannedEntries
1942
- },
1943
- issues: this.issues
1944
- };
1945
- }
1946
- };
1947
- //#endregion
1948
- //#region src/tools/doctor.ts
1949
- function registerDoctorTools(server, writer, config) {
1950
- const targetBranch = config.branch || "contentrain";
1951
- const stagingPrefix = `${targetBranch}--doctor-fix/`;
1952
- function isStagingBranch(branch) {
1953
- return branch.startsWith(stagingPrefix);
1954
- }
1955
- server.registerTool("contentrain_doctor", {
1956
- description: [
1957
- "Run a full diagnostic scan on all Contentrain models, content, and assets.",
1958
- "Detects: duplicate/invalid IDs, type mismatches, broken relations, missing assets,",
1959
- "locale ID inconsistencies, orphan directories, MD/MDX sync issues, unique constraint",
1960
- "violations, missing system fields, and invalid JSON files.",
1961
- "Use fix=true to auto-repair fixable issues — changes are pushed to a staging branch",
1962
- "for review. Use contentrain_doctor_apply to merge the staging branch into the target.",
1963
- "Use modelId to scope the scan to a single model."
1964
- ].join(" "),
1965
- annotations: { readOnlyHint: false },
1966
- inputSchema: {
1967
- fix: z.boolean().optional().default(false).describe("Auto-fix repairable issues (duplicate IDs, missing timestamps, invalid status). Pushes to a staging branch for review."),
1968
- modelId: z.string().optional().describe("Scope scan to a specific model ID. Omit to scan all models.")
1969
- }
1970
- }, async ({ fix, modelId }) => {
1971
- try {
1972
- if (fix) {
1973
- const stagingBranch = `${stagingPrefix}${Date.now()}`;
1974
- const { result, hasChanges } = await writer.stagingTransaction(async (tx) => {
1975
- return new Doctor(tx.basePath, tx.repoPath).diagnose({
1976
- fix: true,
1977
- modelId
1978
- });
1979
- }, {
1980
- message: `doctor fix${modelId ? ` ${modelId}` : ""}`,
1981
- stagingBranch
1982
- });
1983
- return { content: [{
1984
- type: "text",
1985
- text: formatResult(result, hasChanges ? stagingBranch : void 0)
1986
- }] };
1987
- }
1988
- const contentrainDir = config.contentrainDir || "contentrain";
1989
- return { content: [{
1990
- type: "text",
1991
- text: formatResult(await new Doctor(join(config.repoPath, contentrainDir), config.repoPath).diagnose({
1992
- fix: false,
1993
- modelId
1994
- }))
1995
- }] };
1996
- } catch (err) {
1997
- return {
1998
- content: [{
1999
- type: "text",
2000
- text: `Error: ${err}`
2001
- }],
2002
- isError: true
2003
- };
2004
- }
2005
- });
2006
- server.registerTool("contentrain_doctor_apply", {
2007
- description: [
2008
- "Apply a staged doctor fix by merging the staging branch into the target branch.",
2009
- "Use the stagingBranch value returned by contentrain_doctor fix=true.",
2010
- "Attempts fast-forward merge first, falls back to merge commit.",
2011
- "Deletes the staging branch after successful merge."
2012
- ].join(" "),
2013
- annotations: { readOnlyHint: false },
2014
- inputSchema: { branch: z.string().describe(`The staging branch name returned by contentrain_doctor fix=true (e.g., "${stagingPrefix}1234567890")`) }
2015
- }, async ({ branch }) => {
2016
- try {
2017
- if (!isStagingBranch(branch)) return {
2018
- content: [{
2019
- type: "text",
2020
- text: `Error: branch must start with "${stagingPrefix}" — only doctor staging branches for the "${targetBranch}" target can be applied.`
2021
- }],
2022
- isError: true
2023
- };
2024
- return { content: [{
2025
- type: "text",
2026
- text: `✅ Doctor fix applied to "${targetBranch}".\nCommit: ${await writer.applyStagingBranch(branch)}\nStaging branch "${branch}" has been deleted.`
2027
- }] };
2028
- } catch (err) {
2029
- return {
2030
- content: [{
2031
- type: "text",
2032
- text: `Error: ${err}`
2033
- }],
2034
- isError: true
2035
- };
2036
- }
2037
- });
2038
- server.registerTool("contentrain_doctor_discard", {
2039
- description: "Discard a staged doctor fix by deleting the staging branch without merging.",
2040
- annotations: { readOnlyHint: false },
2041
- inputSchema: { branch: z.string().describe(`The staging branch name to discard (e.g., "${stagingPrefix}1234567890")`) }
2042
- }, async ({ branch }) => {
2043
- try {
2044
- if (!isStagingBranch(branch)) return {
2045
- content: [{
2046
- type: "text",
2047
- text: `Error: branch must start with "${stagingPrefix}" — only doctor staging branches can be discarded.`
2048
- }],
2049
- isError: true
2050
- };
2051
- await writer.discardStagingBranch(branch);
2052
- return { content: [{
2053
- type: "text",
2054
- text: `Staging branch "${branch}" has been deleted.`
2055
- }] };
2056
- } catch (err) {
2057
- return {
2058
- content: [{
2059
- type: "text",
2060
- text: `Error: ${err}`
2061
- }],
2062
- isError: true
2063
- };
2064
- }
2065
- });
2066
- }
2067
- function formatResult(result, stagingBranch) {
2068
- const { summary, issues } = result;
2069
- const lines = [];
2070
- if (summary.errors === 0 && summary.warnings === 0 && summary.fixed === 0) lines.push("✅ No issues found!");
2071
- else if (summary.errors === 0 && summary.fixed > 0) lines.push("✅ All fixable issues have been repaired.");
2072
- else if (summary.errors === 0) lines.push("⚠️ No errors, but some warnings found.");
2073
- else lines.push("❌ Issues detected.");
2074
- lines.push("");
2075
- lines.push(`Models scanned: ${summary.models}`);
2076
- lines.push(`Entries scanned: ${summary.entries}`);
2077
- lines.push(`Errors: ${summary.errors}`);
2078
- lines.push(`Warnings: ${summary.warnings}`);
2079
- if (summary.fixed > 0) lines.push(`Fixed: ${summary.fixed}`);
2080
- if (stagingBranch) {
2081
- lines.push("");
2082
- lines.push(`📦 Staging branch: ${stagingBranch}`);
2083
- lines.push("→ Review the changes, then run contentrain_doctor_apply to merge into the target branch.");
2084
- lines.push("→ Or run contentrain_doctor_discard to discard without applying.");
2085
- }
2086
- if (issues.length > 0) {
2087
- lines.push("");
2088
- const grouped = /* @__PURE__ */ new Map();
2089
- for (const issue of issues) {
2090
- const key = issue.category;
2091
- const arr = grouped.get(key) ?? [];
2092
- arr.push(issue);
2093
- grouped.set(key, arr);
2094
- }
2095
- const categoryLabels = {
2096
- structure: "📁 Structure",
2097
- id: "🔑 IDs",
2098
- schema: "📋 Schema",
2099
- relation: "🔗 Relations",
2100
- asset: "🖼️ Assets",
2101
- sync: "📝 MD/MDX Sync"
2102
- };
2103
- for (const [category, categoryIssues] of grouped) {
2104
- lines.push(`\n### ${categoryLabels[category] || category}`);
2105
- for (const issue of categoryIssues) {
2106
- const icon = issue.fix ? "✅" : issue.severity === "error" ? "❌" : "⚠️";
2107
- const location = [
2108
- issue.modelId,
2109
- issue.entryId,
2110
- issue.field
2111
- ].filter(Boolean).join(" → ");
2112
- const filePart = issue.file ? ` (${issue.file})` : "";
2113
- const fixPart = issue.fix ? ` [FIX: ${issue.fix}]` : "";
2114
- lines.push(` ${icon} ${location ? `[${location}]` : ""}${filePart} ${issue.message}${fixPart}`);
2115
- }
2116
- }
2117
- }
2118
- lines.push("\n---\n");
2119
- lines.push(JSON.stringify(result, null, 2));
2120
- return lines.join("\n");
2121
- }
2122
- //#endregion
2123
- //#region src/server.ts
2124
- function createServer(config) {
2125
- const server = new McpServer({
2126
- name: "contentrain",
2127
- version: "0.1.1"
2128
- });
2129
- const writer = new ContentrainWriter(config);
2130
- registerModelTools(server, writer);
2131
- registerContentTools(server, writer);
2132
- registerAssetTools(server, writer);
2133
- registerDoctorTools(server, writer, config);
2134
- return server;
2135
- }
2136
- //#endregion
2137
14
  //#region src/index.ts
2138
- const repoPath = process.env.CONTENTRAIN_REPO_PATH || process.cwd();
2139
- const branch = process.env.CONTENTRAIN_BRANCH || "contentrain";
2140
- const remote = process.env.CONTENTRAIN_REMOTE || "origin";
2141
- const contentrainDir = process.env.CONTENTRAIN_DIR || "contentrain";
2142
- const dryRun = process.env.CONTENTRAIN_DRY_RUN === "true";
2143
- const authorName = process.env.CONTENTRAIN_AUTHOR_NAME;
2144
- const authorEmail = process.env.CONTENTRAIN_AUTHOR_EMAIL;
2145
- const server = createServer({
2146
- repoPath,
2147
- branch,
2148
- remote,
2149
- contentrainDir,
2150
- dryRun,
2151
- author: authorName && authorEmail ? {
2152
- name: authorName,
2153
- email: authorEmail
2154
- } : void 0
2155
- });
2156
- async function main() {
2157
- const transport = new StdioServerTransport();
2158
- await server.connect(transport);
2159
- }
2160
- main().catch((err) => {
2161
- console.error("Failed to start Contentrain MCP server:", err);
2162
- process.exit(1);
2163
- });
15
+ const server = createServer(resolve(process.env["CONTENTRAIN_PROJECT_ROOT"] ?? process.cwd()));
16
+ const transport = new StdioServerTransport();
17
+ await server.connect(transport);
2164
18
  //#endregion
2165
- export { ContentrainWriter, Doctor, createServer };
19
+ export {};
2166
20
 
2167
21
  //# sourceMappingURL=index.mjs.map