@contentrain/mcp 0.1.1 → 0.3.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/README.md +9 -1
- package/dist/index.d.mts +88 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +681 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -904,6 +904,87 @@ var GitSyncManager = class {
|
|
|
904
904
|
}
|
|
905
905
|
}
|
|
906
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
|
+
/**
|
|
907
988
|
* Resolve push conflicts via structural JSON merge.
|
|
908
989
|
*/
|
|
909
990
|
async resolveAndRetry(ctx) {
|
|
@@ -987,6 +1068,31 @@ var ContentrainWriter = class {
|
|
|
987
1068
|
}, options?.message || "content update");
|
|
988
1069
|
}
|
|
989
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
|
+
/**
|
|
990
1096
|
* Dry-run: copy contentrain dir to temp, run operations there, then discard.
|
|
991
1097
|
* The original repository is never modified.
|
|
992
1098
|
*/
|
|
@@ -1007,6 +1113,8 @@ var ContentrainWriter = class {
|
|
|
1007
1113
|
createManagers(repoPath) {
|
|
1008
1114
|
const basePath = join(repoPath, this.contentrainDir);
|
|
1009
1115
|
return {
|
|
1116
|
+
basePath,
|
|
1117
|
+
repoPath,
|
|
1010
1118
|
model: new ModelManager(basePath),
|
|
1011
1119
|
content: new ContentManager(basePath, repoPath),
|
|
1012
1120
|
asset: new AssetManager(basePath, repoPath, this.config),
|
|
@@ -1442,16 +1550,587 @@ function registerAssetTools(server, writer) {
|
|
|
1442
1550
|
});
|
|
1443
1551
|
}
|
|
1444
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
|
|
1445
2123
|
//#region src/server.ts
|
|
1446
2124
|
function createServer(config) {
|
|
1447
2125
|
const server = new McpServer({
|
|
1448
2126
|
name: "contentrain",
|
|
1449
|
-
version: "0.1.
|
|
2127
|
+
version: "0.1.1"
|
|
1450
2128
|
});
|
|
1451
2129
|
const writer = new ContentrainWriter(config);
|
|
1452
2130
|
registerModelTools(server, writer);
|
|
1453
2131
|
registerContentTools(server, writer);
|
|
1454
2132
|
registerAssetTools(server, writer);
|
|
2133
|
+
registerDoctorTools(server, writer, config);
|
|
1455
2134
|
return server;
|
|
1456
2135
|
}
|
|
1457
2136
|
//#endregion
|
|
@@ -1483,6 +2162,6 @@ main().catch((err) => {
|
|
|
1483
2162
|
process.exit(1);
|
|
1484
2163
|
});
|
|
1485
2164
|
//#endregion
|
|
1486
|
-
export { ContentrainWriter, createServer };
|
|
2165
|
+
export { ContentrainWriter, Doctor, createServer };
|
|
1487
2166
|
|
|
1488
2167
|
//# sourceMappingURL=index.mjs.map
|