@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/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.0"
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