@contentrain/mcp 3.6.0 → 3.6.2

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 (73) hide show
  1. package/README.md +3 -1
  2. package/dist/{apply-manager-eZ6zA99Y.mjs → apply-manager-llmSyK5K.mjs} +7 -7
  3. package/dist/{apply-manager-eZ6zA99Y.mjs.map → apply-manager-llmSyK5K.mjs.map} +1 -1
  4. package/dist/branch-lifecycle-DGlgjnC7.d.mts.map +1 -1
  5. package/dist/{branch-lifecycle-DUOoyNWn.mjs → branch-lifecycle-f4gI_eGo.mjs} +71 -5
  6. package/dist/branch-lifecycle-f4gI_eGo.mjs.map +1 -0
  7. package/dist/{config-B8a4JBT1.mjs → config-BLwZtwlI.mjs} +2 -2
  8. package/dist/{config-B8a4JBT1.mjs.map → config-BLwZtwlI.mjs.map} +1 -1
  9. package/dist/{context-BHfO4WBv.mjs → context-fJCKH_qv.mjs} +6 -6
  10. package/dist/{context-BHfO4WBv.mjs.map → context-fJCKH_qv.mjs.map} +1 -1
  11. package/dist/core/apply-manager.mjs +8 -8
  12. package/dist/core/config.mjs +2 -2
  13. package/dist/core/content-manager.mjs +3 -3
  14. package/dist/core/context.mjs +5 -5
  15. package/dist/core/doctor.mjs +7 -7
  16. package/dist/core/graph-builder.mjs +3 -3
  17. package/dist/core/meta-manager.mjs +2 -2
  18. package/dist/core/model-manager.mjs +3 -3
  19. package/dist/core/ops/index.d.mts.map +1 -1
  20. package/dist/core/ops/index.mjs +8 -8
  21. package/dist/core/scan-config.mjs +2 -2
  22. package/dist/core/scanner.mjs +3 -3
  23. package/dist/core/validator/index.mjs +5 -5
  24. package/dist/{detect-CI32ynpq.mjs → detect-DUYFv3-O.mjs} +2 -2
  25. package/dist/{detect-CI32ynpq.mjs.map → detect-DUYFv3-O.mjs.map} +1 -1
  26. package/dist/{doctor-VnOzDTbk.mjs → doctor-fIG4TtsO.mjs} +6 -6
  27. package/dist/{doctor-VnOzDTbk.mjs.map → doctor-fIG4TtsO.mjs.map} +1 -1
  28. package/dist/{fs-DJv5G8uL.mjs → fs-BFTuXbbw.mjs} +2 -2
  29. package/dist/{fs-DJv5G8uL.mjs.map → fs-BFTuXbbw.mjs.map} +1 -1
  30. package/dist/git/branch-lifecycle.mjs +3 -3
  31. package/dist/git/reconcile.d.mts.map +1 -1
  32. package/dist/git/reconcile.mjs +10 -10
  33. package/dist/git/transaction.d.mts +4 -0
  34. package/dist/git/transaction.d.mts.map +1 -1
  35. package/dist/git/transaction.mjs +7 -7
  36. package/dist/{graph-builder-C9NS7y-a.mjs → graph-builder-CNswWNtb.mjs} +3 -3
  37. package/dist/{graph-builder-C9NS7y-a.mjs.map → graph-builder-CNswWNtb.mjs.map} +1 -1
  38. package/dist/index.mjs +19 -19
  39. package/dist/{local-DgjDad1x.mjs → local-DkDTrGt_.mjs} +140 -13
  40. package/dist/local-DkDTrGt_.mjs.map +1 -0
  41. package/dist/{meta-manager-Cd6AfuGS.mjs → meta-manager-CSpKtjnP.mjs} +2 -2
  42. package/dist/{meta-manager-Cd6AfuGS.mjs.map → meta-manager-CSpKtjnP.mjs.map} +1 -1
  43. package/dist/{model-manager-CfwJWvz7.mjs → model-manager-DPXulg7z.mjs} +4 -4
  44. package/dist/{model-manager-CfwJWvz7.mjs.map → model-manager-DPXulg7z.mjs.map} +1 -1
  45. package/dist/{ops-B2P11b8v.mjs → ops-DxhCCs_Z.mjs} +5 -88
  46. package/dist/ops-DxhCCs_Z.mjs.map +1 -0
  47. package/dist/providers/local/index.d.mts.map +1 -1
  48. package/dist/providers/local/index.mjs +11 -11
  49. package/dist/{reconcile-B55dZqhg.mjs → reconcile-CPDGFQ8H.mjs} +5 -5
  50. package/dist/{reconcile-B55dZqhg.mjs.map → reconcile-CPDGFQ8H.mjs.map} +1 -1
  51. package/dist/{reconcile-CwS4LUbo.mjs → reconcile-Cq60cldB.mjs} +11 -7
  52. package/dist/reconcile-Cq60cldB.mjs.map +1 -0
  53. package/dist/{scan-config-C5zzygeH.mjs → scan-config-DkRKu_ON.mjs} +2 -2
  54. package/dist/{scan-config-C5zzygeH.mjs.map → scan-config-DkRKu_ON.mjs.map} +1 -1
  55. package/dist/{scanner-CEiT8Tgx.mjs → scanner-BLo3ssiF.mjs} +3 -3
  56. package/dist/{scanner-CEiT8Tgx.mjs.map → scanner-BLo3ssiF.mjs.map} +1 -1
  57. package/dist/server/http/index.mjs +19 -19
  58. package/dist/{server-CCn_fR9q.mjs → server-BRXP35b8.mjs} +21 -21
  59. package/dist/server-BRXP35b8.mjs.map +1 -0
  60. package/dist/server.mjs +19 -19
  61. package/dist/{transaction-BR2jg4uj.mjs → transaction-BWJt1KQb.mjs} +77 -39
  62. package/dist/transaction-BWJt1KQb.mjs.map +1 -0
  63. package/dist/util/detect.mjs +2 -2
  64. package/dist/util/fs.mjs +1 -1
  65. package/dist/{validator-BD0Z0O0_.mjs → validator-C08p1OpM.mjs} +6 -6
  66. package/dist/{validator-BD0Z0O0_.mjs.map → validator-C08p1OpM.mjs.map} +1 -1
  67. package/package.json +2 -2
  68. package/dist/branch-lifecycle-DUOoyNWn.mjs.map +0 -1
  69. package/dist/local-DgjDad1x.mjs.map +0 -1
  70. package/dist/ops-B2P11b8v.mjs.map +0 -1
  71. package/dist/reconcile-CwS4LUbo.mjs.map +0 -1
  72. package/dist/server-CCn_fR9q.mjs.map +0 -1
  73. package/dist/transaction-BR2jg4uj.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"reconcile.d.mts","names":[],"sources":["../../src/git/reconcile.ts"],"mappings":";;;;UAoBiB,wBAAA;;EAEf,MAAA;EAFe;EAIf,WAAA,GAAc,kBAAA;;EAEd,MAAA,GAAS,aAAA;AAAA;AAAA,UAGM,uBAAA;EALD;;;;;AAKhB;;EAQE,MAAA;EACA,IAAA,EAAM,aAAA;EACN,IAAA;EACA,UAAA;EACA,MAAA;EACA,YAAA,GAAe,WAAA;EACf,IAAA,GAAO,UAAA;EACP,WAAA,GAAc,UAAA;AAAA;;;;;;;;;;;;;AAmBhB;;;;iBAAsB,iBAAA,CACpB,WAAA,UACA,OAAA,GAAS,wBAAA,GACR,OAAA,CAAQ,uBAAA"}
1
+ {"version":3,"file":"reconcile.d.mts","names":[],"sources":["../../src/git/reconcile.ts"],"mappings":";;;;UAqBiB,wBAAA;;EAEf,MAAA;EAFe;EAIf,WAAA,GAAc,kBAAA;;EAEd,MAAA,GAAS,aAAA;AAAA;AAAA,UAGM,uBAAA;EALD;;;;;AAKhB;;EAQE,MAAA;EACA,IAAA,EAAM,aAAA;EACN,IAAA;EACA,UAAA;EACA,MAAA;EACA,YAAA,GAAe,WAAA;EACf,IAAA,GAAO,UAAA;EACP,WAAA,GAAc,UAAA;AAAA;;;;;;;;;;;;;AAmBhB;;;;iBAAsB,iBAAA,CACpB,WAAA,UACA,OAAA,GAAS,wBAAA,GACR,OAAA,CAAQ,uBAAA"}
@@ -1,13 +1,13 @@
1
- import "../reconcile-B55dZqhg.mjs";
2
- import "../context-BHfO4WBv.mjs";
3
- import "../model-manager-CfwJWvz7.mjs";
4
- import "../fs-DJv5G8uL.mjs";
5
- import "../meta-manager-Cd6AfuGS.mjs";
6
- import "../config-B8a4JBT1.mjs";
7
- import "../validator-BD0Z0O0_.mjs";
1
+ import "../reconcile-CPDGFQ8H.mjs";
2
+ import "../context-fJCKH_qv.mjs";
3
+ import "../model-manager-DPXulg7z.mjs";
4
+ import "../fs-BFTuXbbw.mjs";
5
+ import "../meta-manager-CSpKtjnP.mjs";
6
+ import "../config-BLwZtwlI.mjs";
7
+ import "../validator-C08p1OpM.mjs";
8
8
  import "../identity-CxAB9Qb8.mjs";
9
- import "../branch-lifecycle-DUOoyNWn.mjs";
9
+ import "../branch-lifecycle-f4gI_eGo.mjs";
10
10
  import "../id-CjGMPP1k.mjs";
11
- import "../transaction-BR2jg4uj.mjs";
12
- import { t as reconcileBranches } from "../reconcile-CwS4LUbo.mjs";
11
+ import "../transaction-BWJt1KQb.mjs";
12
+ import { t as reconcileBranches } from "../reconcile-Cq60cldB.mjs";
13
13
  export { reconcileBranches };
@@ -46,6 +46,10 @@ interface MergeBranchResult {
46
46
  interface GitTransaction {
47
47
  worktree: string;
48
48
  branch: string;
49
+ /** The resolved base branch this transaction advances. */
50
+ baseBranch: string;
51
+ /** Whether the developer has the base branch checked out. */
52
+ baseCheckedOut: boolean;
49
53
  write(callback: (worktreePath: string) => Promise<void>): Promise<void>;
50
54
  commit(message: string, contextUpdate?: ContextUpdate): Promise<string>;
51
55
  complete(): Promise<CompleteResult>;
@@ -1 +1 @@
1
- {"version":3,"file":"transaction.d.mts","names":[],"sources":["../../src/git/transaction.ts"],"mappings":";;;;;;;AAoBA;;;cAAa,kBAAA;AAAA,UAEI,aAAA;EACf,IAAA;EACA,KAAA;EACA,MAAA;EACA,OAAA;AAAA;;;;;;AAWF;;;UAAiB,cAAA;EACf,MAAA;EACA,MAAA;EACA,IAAA,GAAO,UAAA;EACP,OAAA;EAHA;EAKA,YAAA,GAAe,WAAA;EAHf;EAKA,WAAA,GAAc,UAAA;AAAA;;UAIC,iBAAA;EACf,MAAA;EACA,MAAA;EACA,IAAA,EAAM,UAAA;EACN,YAAA,EAAc,WAAA;EACd,WAAA,EAAa,UAAA;EACb,OAAA;EACA,MAAA,GAAS,kBAAA;AAAA;AAAA,UAGM,cAAA;EACf,QAAA;EACA,MAAA;EACA,KAAA,CAAM,QAAA,GAAW,YAAA,aAAyB,OAAA,SAAgB,OAAA;EAC1D,MAAA,CAAO,OAAA,UAAiB,aAAA,GAAgB,aAAA,GAAgB,OAAA;EACxD,QAAA,IAAY,OAAA,CAAQ,cAAA;EACpB,OAAA,IAAW,OAAA;AAAA;AAAA,iBAGS,mBAAA,CAAoB,WAAA,WAAsB,OAAA;AAAA,iBAiC1C,aAAA,CACpB,WAAA,UACA,aAAA,UACA,cAAA,UACA,gBAAA,WACA,sBAAA,GAAyB,GAAA,WACxB,OAAA,CAAQ,UAAA;AAAA,iBAqGW,iBAAA,CACpB,WAAA,UACA,UAAA,UACA,OAAA;EAAY,gBAAA,GAAmB,YAAA;AAAA,IAC9B,OAAA,CAAQ,cAAA;AAAA,iBAiQW,WAAA,CACpB,WAAA,UACA,UAAA,WACC,OAAA,CAAQ,iBAAA;AAAA,iBAoKK,eAAA,CAAgB,KAAA,UAAe,MAAA,UAAgB,MAAA;;;;AAjkB/D;;;iBA+kBsB,UAAA,CAAW,GAAA,EAAK,SAAA,EAAW,QAAA,UAAkB,UAAA,WAAqB,OAAA;;;;;;;;;;;;iBAoBlE,mBAAA,CACpB,WAAA,UACA,YAAA,UACA,KAAA,EAAO,SAAA,EACP,UAAA,UACA,UAAA,UACA,YAAA,YACC,OAAA,CAAQ,UAAA"}
1
+ {"version":3,"file":"transaction.d.mts","names":[],"sources":["../../src/git/transaction.ts"],"mappings":";;;;;;;AAqBA;;;cAAa,kBAAA;AAAA,UAEI,aAAA;EACf,IAAA;EACA,KAAA;EACA,MAAA;EACA,OAAA;AAAA;;;;;;AAWF;;;UAAiB,cAAA;EACf,MAAA;EACA,MAAA;EACA,IAAA,GAAO,UAAA;EACP,OAAA;EAHA;EAKA,YAAA,GAAe,WAAA;EAHf;EAKA,WAAA,GAAc,UAAA;AAAA;;UAIC,iBAAA;EACf,MAAA;EACA,MAAA;EACA,IAAA,EAAM,UAAA;EACN,YAAA,EAAc,WAAA;EACd,WAAA,EAAa,UAAA;EACb,OAAA;EACA,MAAA,GAAS,kBAAA;AAAA;AAAA,UAGM,cAAA;EACf,QAAA;EACA,MAAA;EAL2B;EAO3B,UAAA;EAbA;EAeA,cAAA;EACA,KAAA,CAAM,QAAA,GAAW,YAAA,aAAyB,OAAA,SAAgB,OAAA;EAC1D,MAAA,CAAO,OAAA,UAAiB,aAAA,GAAgB,aAAA,GAAgB,OAAA;EACxD,QAAA,IAAY,OAAA,CAAQ,cAAA;EACpB,OAAA,IAAW,OAAA;AAAA;AAAA,iBAGS,mBAAA,CAAoB,WAAA,WAAsB,OAAA;AAAA,iBA+B1C,aAAA,CACpB,WAAA,UACA,aAAA,UACA,cAAA,UACA,gBAAA,WACA,sBAAA,GAAyB,GAAA,WACxB,OAAA,CAAQ,UAAA;AAAA,iBAqGW,iBAAA,CACpB,WAAA,UACA,UAAA,UACA,OAAA;EAAY,gBAAA,GAAmB,YAAA;AAAA,IAC9B,OAAA,CAAQ,cAAA;AAAA,iBAoPW,WAAA,CACpB,WAAA,UACA,UAAA,WACC,OAAA,CAAQ,iBAAA;AAAA,iBA2JK,eAAA,CAAgB,KAAA,UAAe,MAAA,UAAgB,MAAA;;;;;;;iBAgEzC,UAAA,CAAW,GAAA,EAAK,SAAA,EAAW,QAAA,UAAkB,UAAA,WAAqB,OAAA;;;;;;;;;;;;iBAoBlE,mBAAA,CACpB,WAAA,UACA,YAAA,UACA,KAAA,EAAO,SAAA,EACP,UAAA,UACA,UAAA,UACA,YAAA,YACC,OAAA,CAAQ,UAAA"}
@@ -1,10 +1,10 @@
1
- import "../context-BHfO4WBv.mjs";
2
- import "../model-manager-CfwJWvz7.mjs";
3
- import "../fs-DJv5G8uL.mjs";
4
- import "../meta-manager-Cd6AfuGS.mjs";
5
- import "../config-B8a4JBT1.mjs";
1
+ import "../context-fJCKH_qv.mjs";
2
+ import "../model-manager-DPXulg7z.mjs";
3
+ import "../fs-BFTuXbbw.mjs";
4
+ import "../meta-manager-CSpKtjnP.mjs";
5
+ import "../config-BLwZtwlI.mjs";
6
6
  import "../identity-CxAB9Qb8.mjs";
7
- import "../branch-lifecycle-DUOoyNWn.mjs";
7
+ import "../branch-lifecycle-f4gI_eGo.mjs";
8
8
  import "../id-CjGMPP1k.mjs";
9
- import { a as isAncestor, c as selectiveSync, i as ensureContentBranch, n as buildBranchName, o as mergeBranch, r as createTransaction, s as pushContentBranches, t as NETWORK_TIMEOUT_MS } from "../transaction-BR2jg4uj.mjs";
9
+ import { a as isAncestor, c as selectiveSync, i as ensureContentBranch, n as buildBranchName, o as mergeBranch, r as createTransaction, s as pushContentBranches, t as NETWORK_TIMEOUT_MS } from "../transaction-BWJt1KQb.mjs";
10
10
  export { NETWORK_TIMEOUT_MS, buildBranchName, createTransaction, ensureContentBranch, isAncestor, mergeBranch, pushContentBranches, selectiveSync };
@@ -1,5 +1,5 @@
1
- import { o as readText, r as pathExists } from "./fs-DJv5G8uL.mjs";
2
- import { a as classifyFile, i as autoDetectSourceDirs, n as SCAN_EXTENSIONS, o as discoverFiles } from "./scan-config-C5zzygeH.mjs";
1
+ import { o as readText, r as pathExists } from "./fs-BFTuXbbw.mjs";
2
+ import { a as classifyFile, i as autoDetectSourceDirs, n as SCAN_EXTENSIONS, o as discoverFiles } from "./scan-config-DkRKu_ON.mjs";
3
3
  import { dirname, extname, join, relative } from "node:path";
4
4
  //#region src/core/graph-builder.ts
5
5
  const MAX_ORPHANS = 10;
@@ -159,4 +159,4 @@ async function buildGraph(projectRoot, options) {
159
159
  //#endregion
160
160
  export { buildGraph as t };
161
161
 
162
- //# sourceMappingURL=graph-builder-C9NS7y-a.mjs.map
162
+ //# sourceMappingURL=graph-builder-CNswWNtb.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"graph-builder-C9NS7y-a.mjs","names":[],"sources":["../src/core/graph-builder.ts"],"sourcesContent":["import type { FileCategory, GraphNode, ProjectGraph } from '@contentrain/types'\nimport { join, relative, dirname, extname } from 'node:path'\nimport { readText, pathExists } from '../util/fs.js'\nimport {\n SCAN_EXTENSIONS,\n classifyFile,\n autoDetectSourceDirs,\n discoverFiles,\n} from './scan-config.js'\n\nexport interface BuildGraphOptions {\n paths?: string[]\n include?: string[]\n exclude?: string[]\n}\n\nconst MAX_ORPHANS = 10\n\n// ---------------------------------------------------------------------------\n// Import extraction\n// ---------------------------------------------------------------------------\n\nconst IMPORT_RE = /(?:import|export)\\s+.*?from\\s+['\"]([^'\"]+)['\"]|(?:import|require)\\s*\\(\\s*['\"]([^'\"]+)['\"]\\s*\\)/g\n\nfunction extractImportPaths(content: string): string[] {\n const paths: string[] = []\n let m: RegExpExecArray | null\n // Reset lastIndex before use since we reuse the regex\n IMPORT_RE.lastIndex = 0\n while ((m = IMPORT_RE.exec(content)) !== null) {\n const p = m[1] ?? m[2]\n if (p && isLocalImport(p)) {\n paths.push(p)\n }\n }\n return paths\n}\n\nfunction isLocalImport(p: string): boolean {\n return p.startsWith('.') || p.startsWith('/')\n}\n\n// ---------------------------------------------------------------------------\n// Component name extraction from imports\n// ---------------------------------------------------------------------------\n\nconst NAMED_IMPORT_RE = /import\\s+(?:type\\s+)?(?:(\\w+)(?:\\s*,\\s*)?)?(?:\\{([^}]*)\\})?\\s+from\\s+['\"][^'\"]+['\"]/g\nconst DEFAULT_IMPORT_RE = /import\\s+(?:type\\s+)?(\\w+)\\s+from\\s+['\"][^'\"]+['\"]/g\n\nfunction isPascalCase(name: string): boolean {\n return /^[A-Z][a-zA-Z0-9]*$/.test(name)\n}\n\nfunction extractComponentNames(content: string): string[] {\n const names = new Set<string>()\n\n // Default imports: `import Button from '...'`\n DEFAULT_IMPORT_RE.lastIndex = 0\n let m: RegExpExecArray | null\n while ((m = DEFAULT_IMPORT_RE.exec(content)) !== null) {\n const name = m[1]\n if (name && isPascalCase(name)) {\n names.add(name)\n }\n }\n\n // Named imports: `import { Button, useHook } from '...'`\n NAMED_IMPORT_RE.lastIndex = 0\n while ((m = NAMED_IMPORT_RE.exec(content)) !== null) {\n // Default part before destructuring\n const defaultName = m[1]\n if (defaultName && isPascalCase(defaultName)) {\n names.add(defaultName)\n }\n // Destructured names\n const namedPart = m[2]\n if (namedPart) {\n for (const segment of namedPart.split(',')) {\n // Handle `Foo as Bar` — take the local name (Bar)\n const parts = segment.trim().split(/\\s+as\\s+/)\n const localName = (parts[1] ?? parts[0])?.trim()\n if (localName && isPascalCase(localName)) {\n names.add(localName)\n }\n }\n }\n }\n\n return [...names]\n}\n\n// ---------------------------------------------------------------------------\n// String counting (estimate)\n// ---------------------------------------------------------------------------\n\nconst STRING_SINGLE_RE = /'[^'\\\\]*(?:\\\\.[^'\\\\]*)*'/g\nconst STRING_DOUBLE_RE = /\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*\"/g\nconst STRING_TEMPLATE_RE = /`[^`\\\\]*(?:\\\\.[^`\\\\]*)*`/g\n\nfunction countStrings(content: string): number {\n // Strip import/export/require lines first so their string literals don't count\n const stripped = content.replace(/^(?:import|export)\\s+.*$/gm, '')\n .replace(/\\brequire\\s*\\([^)]*\\)/g, '')\n\n const singles = stripped.match(STRING_SINGLE_RE)?.length ?? 0\n const doubles = stripped.match(STRING_DOUBLE_RE)?.length ?? 0\n const templates = stripped.match(STRING_TEMPLATE_RE)?.length ?? 0\n\n return singles + doubles + templates\n}\n\n// ---------------------------------------------------------------------------\n// Import path resolution\n// ---------------------------------------------------------------------------\n\nconst RESOLVE_EXTENSIONS = [...SCAN_EXTENSIONS]\nconst RESOLVE_INDEX_FILES = ['index.ts', 'index.tsx', 'index.js']\n\nasync function resolveImportPath(\n importPath: string,\n importerDir: string,\n projectRoot: string,\n): Promise<string | null> {\n const base = importPath.startsWith('/')\n ? join(projectRoot, importPath)\n : join(importerDir, importPath)\n\n // If it already has an extension, check directly\n if (extname(base)) {\n if (await pathExists(base)) {\n return base\n }\n return null\n }\n\n // Try adding extensions\n for (const ext of RESOLVE_EXTENSIONS) {\n const candidate = base + ext\n if (await pathExists(candidate)) {\n return candidate\n }\n }\n\n // Try index files inside directory\n for (const idx of RESOLVE_INDEX_FILES) {\n const candidate = join(base, idx)\n if (await pathExists(candidate)) {\n return candidate\n }\n }\n\n return null\n}\n\n// ---------------------------------------------------------------------------\n// Main\n// ---------------------------------------------------------------------------\n\ninterface FileInfo {\n relPath: string\n imports: string[]\n components: string[]\n strings: number\n category: FileCategory\n}\n\nexport async function buildGraph(\n projectRoot: string,\n options?: BuildGraphOptions,\n): Promise<ProjectGraph> {\n const scanDirs = options?.paths ?? await autoDetectSourceDirs(projectRoot)\n\n // ---- File discovery (shared with scanner) ----\n const filePaths = await discoverFiles(projectRoot, {\n paths: scanDirs,\n include: options?.include,\n exclude: options?.exclude,\n })\n\n // ---- Pass 1: Parse each file ----\n const fileMap = new Map<string, FileInfo>()\n\n const parsePromises = filePaths.map(async (relPath) => {\n const absPath = join(projectRoot, relPath)\n const content = await readText(absPath)\n if (content === null) return\n\n const imports = extractImportPaths(content)\n const components = extractComponentNames(content)\n const strings = countStrings(content)\n const category = classifyFile(relPath)\n\n fileMap.set(relPath, { relPath, imports, components, strings, category })\n })\n\n await Promise.all(parsePromises)\n\n // ---- Pass 2: Resolve imports and build reverse lookup ----\n const usedByMap = new Map<string, Set<string>>()\n\n // Initialize usedBy sets\n for (const relPath of fileMap.keys()) {\n usedByMap.set(relPath, new Set())\n }\n\n const resolvePromises: Promise<void>[] = []\n\n for (const [relPath, info] of fileMap) {\n const importerAbsDir = dirname(join(projectRoot, relPath))\n\n for (const rawImport of info.imports) {\n resolvePromises.push(\n resolveImportPath(rawImport, importerAbsDir, projectRoot).then((resolved) => {\n if (!resolved) return\n const resolvedRel = relative(projectRoot, resolved)\n const targetSet = usedByMap.get(resolvedRel)\n if (targetSet) {\n targetSet.add(relPath)\n }\n }),\n )\n }\n }\n\n await Promise.all(resolvePromises)\n\n // ---- Build nodes ----\n const pages: GraphNode[] = []\n const components: GraphNode[] = []\n const layouts: GraphNode[] = []\n const orphanCandidates: string[] = []\n\n let totalStrings = 0\n\n for (const [relPath, info] of fileMap) {\n const usedBy = [...(usedByMap.get(relPath) ?? [])].toSorted((a, b) => a.localeCompare(b))\n totalStrings += info.strings\n\n // Filter out nodes with 0 strings — not relevant for content extraction\n if (info.strings === 0 && info.category !== 'other') continue\n\n const node: GraphNode = {\n file: relPath,\n category: info.category,\n imports: info.imports,\n used_by: usedBy,\n strings: info.strings,\n }\n\n // Only attach components list for pages\n if (info.category === 'page' && info.components.length > 0) {\n node.components = info.components\n }\n\n switch (info.category) {\n case 'page':\n pages.push(node)\n break\n case 'component':\n components.push(node)\n break\n case 'layout':\n layouts.push(node)\n break\n default:\n // Orphan = \"other\" category with no inbound references and no outbound imports\n if (usedBy.length === 0 && info.imports.length === 0) {\n orphanCandidates.push(relPath)\n }\n break\n }\n }\n\n return {\n pages: pages.toSorted((a, b) => a.file.localeCompare(b.file)),\n components: components.toSorted((a, b) => a.file.localeCompare(b.file)),\n layouts: layouts.toSorted((a, b) => a.file.localeCompare(b.file)),\n orphan_files: orphanCandidates.toSorted((a, b) => a.localeCompare(b)).slice(0, MAX_ORPHANS),\n stats: {\n total_files: fileMap.size,\n total_components: components.length,\n total_pages: pages.length,\n total_strings_estimate: totalStrings,\n },\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"graph-builder-CNswWNtb.mjs","names":[],"sources":["../src/core/graph-builder.ts"],"sourcesContent":["import type { FileCategory, GraphNode, ProjectGraph } from '@contentrain/types'\nimport { join, relative, dirname, extname } from 'node:path'\nimport { readText, pathExists } from '../util/fs.js'\nimport {\n SCAN_EXTENSIONS,\n classifyFile,\n autoDetectSourceDirs,\n discoverFiles,\n} from './scan-config.js'\n\nexport interface BuildGraphOptions {\n paths?: string[]\n include?: string[]\n exclude?: string[]\n}\n\nconst MAX_ORPHANS = 10\n\n// ---------------------------------------------------------------------------\n// Import extraction\n// ---------------------------------------------------------------------------\n\nconst IMPORT_RE = /(?:import|export)\\s+.*?from\\s+['\"]([^'\"]+)['\"]|(?:import|require)\\s*\\(\\s*['\"]([^'\"]+)['\"]\\s*\\)/g\n\nfunction extractImportPaths(content: string): string[] {\n const paths: string[] = []\n let m: RegExpExecArray | null\n // Reset lastIndex before use since we reuse the regex\n IMPORT_RE.lastIndex = 0\n while ((m = IMPORT_RE.exec(content)) !== null) {\n const p = m[1] ?? m[2]\n if (p && isLocalImport(p)) {\n paths.push(p)\n }\n }\n return paths\n}\n\nfunction isLocalImport(p: string): boolean {\n return p.startsWith('.') || p.startsWith('/')\n}\n\n// ---------------------------------------------------------------------------\n// Component name extraction from imports\n// ---------------------------------------------------------------------------\n\nconst NAMED_IMPORT_RE = /import\\s+(?:type\\s+)?(?:(\\w+)(?:\\s*,\\s*)?)?(?:\\{([^}]*)\\})?\\s+from\\s+['\"][^'\"]+['\"]/g\nconst DEFAULT_IMPORT_RE = /import\\s+(?:type\\s+)?(\\w+)\\s+from\\s+['\"][^'\"]+['\"]/g\n\nfunction isPascalCase(name: string): boolean {\n return /^[A-Z][a-zA-Z0-9]*$/.test(name)\n}\n\nfunction extractComponentNames(content: string): string[] {\n const names = new Set<string>()\n\n // Default imports: `import Button from '...'`\n DEFAULT_IMPORT_RE.lastIndex = 0\n let m: RegExpExecArray | null\n while ((m = DEFAULT_IMPORT_RE.exec(content)) !== null) {\n const name = m[1]\n if (name && isPascalCase(name)) {\n names.add(name)\n }\n }\n\n // Named imports: `import { Button, useHook } from '...'`\n NAMED_IMPORT_RE.lastIndex = 0\n while ((m = NAMED_IMPORT_RE.exec(content)) !== null) {\n // Default part before destructuring\n const defaultName = m[1]\n if (defaultName && isPascalCase(defaultName)) {\n names.add(defaultName)\n }\n // Destructured names\n const namedPart = m[2]\n if (namedPart) {\n for (const segment of namedPart.split(',')) {\n // Handle `Foo as Bar` — take the local name (Bar)\n const parts = segment.trim().split(/\\s+as\\s+/)\n const localName = (parts[1] ?? parts[0])?.trim()\n if (localName && isPascalCase(localName)) {\n names.add(localName)\n }\n }\n }\n }\n\n return [...names]\n}\n\n// ---------------------------------------------------------------------------\n// String counting (estimate)\n// ---------------------------------------------------------------------------\n\nconst STRING_SINGLE_RE = /'[^'\\\\]*(?:\\\\.[^'\\\\]*)*'/g\nconst STRING_DOUBLE_RE = /\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*\"/g\nconst STRING_TEMPLATE_RE = /`[^`\\\\]*(?:\\\\.[^`\\\\]*)*`/g\n\nfunction countStrings(content: string): number {\n // Strip import/export/require lines first so their string literals don't count\n const stripped = content.replace(/^(?:import|export)\\s+.*$/gm, '')\n .replace(/\\brequire\\s*\\([^)]*\\)/g, '')\n\n const singles = stripped.match(STRING_SINGLE_RE)?.length ?? 0\n const doubles = stripped.match(STRING_DOUBLE_RE)?.length ?? 0\n const templates = stripped.match(STRING_TEMPLATE_RE)?.length ?? 0\n\n return singles + doubles + templates\n}\n\n// ---------------------------------------------------------------------------\n// Import path resolution\n// ---------------------------------------------------------------------------\n\nconst RESOLVE_EXTENSIONS = [...SCAN_EXTENSIONS]\nconst RESOLVE_INDEX_FILES = ['index.ts', 'index.tsx', 'index.js']\n\nasync function resolveImportPath(\n importPath: string,\n importerDir: string,\n projectRoot: string,\n): Promise<string | null> {\n const base = importPath.startsWith('/')\n ? join(projectRoot, importPath)\n : join(importerDir, importPath)\n\n // If it already has an extension, check directly\n if (extname(base)) {\n if (await pathExists(base)) {\n return base\n }\n return null\n }\n\n // Try adding extensions\n for (const ext of RESOLVE_EXTENSIONS) {\n const candidate = base + ext\n if (await pathExists(candidate)) {\n return candidate\n }\n }\n\n // Try index files inside directory\n for (const idx of RESOLVE_INDEX_FILES) {\n const candidate = join(base, idx)\n if (await pathExists(candidate)) {\n return candidate\n }\n }\n\n return null\n}\n\n// ---------------------------------------------------------------------------\n// Main\n// ---------------------------------------------------------------------------\n\ninterface FileInfo {\n relPath: string\n imports: string[]\n components: string[]\n strings: number\n category: FileCategory\n}\n\nexport async function buildGraph(\n projectRoot: string,\n options?: BuildGraphOptions,\n): Promise<ProjectGraph> {\n const scanDirs = options?.paths ?? await autoDetectSourceDirs(projectRoot)\n\n // ---- File discovery (shared with scanner) ----\n const filePaths = await discoverFiles(projectRoot, {\n paths: scanDirs,\n include: options?.include,\n exclude: options?.exclude,\n })\n\n // ---- Pass 1: Parse each file ----\n const fileMap = new Map<string, FileInfo>()\n\n const parsePromises = filePaths.map(async (relPath) => {\n const absPath = join(projectRoot, relPath)\n const content = await readText(absPath)\n if (content === null) return\n\n const imports = extractImportPaths(content)\n const components = extractComponentNames(content)\n const strings = countStrings(content)\n const category = classifyFile(relPath)\n\n fileMap.set(relPath, { relPath, imports, components, strings, category })\n })\n\n await Promise.all(parsePromises)\n\n // ---- Pass 2: Resolve imports and build reverse lookup ----\n const usedByMap = new Map<string, Set<string>>()\n\n // Initialize usedBy sets\n for (const relPath of fileMap.keys()) {\n usedByMap.set(relPath, new Set())\n }\n\n const resolvePromises: Promise<void>[] = []\n\n for (const [relPath, info] of fileMap) {\n const importerAbsDir = dirname(join(projectRoot, relPath))\n\n for (const rawImport of info.imports) {\n resolvePromises.push(\n resolveImportPath(rawImport, importerAbsDir, projectRoot).then((resolved) => {\n if (!resolved) return\n const resolvedRel = relative(projectRoot, resolved)\n const targetSet = usedByMap.get(resolvedRel)\n if (targetSet) {\n targetSet.add(relPath)\n }\n }),\n )\n }\n }\n\n await Promise.all(resolvePromises)\n\n // ---- Build nodes ----\n const pages: GraphNode[] = []\n const components: GraphNode[] = []\n const layouts: GraphNode[] = []\n const orphanCandidates: string[] = []\n\n let totalStrings = 0\n\n for (const [relPath, info] of fileMap) {\n const usedBy = [...(usedByMap.get(relPath) ?? [])].toSorted((a, b) => a.localeCompare(b))\n totalStrings += info.strings\n\n // Filter out nodes with 0 strings — not relevant for content extraction\n if (info.strings === 0 && info.category !== 'other') continue\n\n const node: GraphNode = {\n file: relPath,\n category: info.category,\n imports: info.imports,\n used_by: usedBy,\n strings: info.strings,\n }\n\n // Only attach components list for pages\n if (info.category === 'page' && info.components.length > 0) {\n node.components = info.components\n }\n\n switch (info.category) {\n case 'page':\n pages.push(node)\n break\n case 'component':\n components.push(node)\n break\n case 'layout':\n layouts.push(node)\n break\n default:\n // Orphan = \"other\" category with no inbound references and no outbound imports\n if (usedBy.length === 0 && info.imports.length === 0) {\n orphanCandidates.push(relPath)\n }\n break\n }\n }\n\n return {\n pages: pages.toSorted((a, b) => a.file.localeCompare(b.file)),\n components: components.toSorted((a, b) => a.file.localeCompare(b.file)),\n layouts: layouts.toSorted((a, b) => a.file.localeCompare(b.file)),\n orphan_files: orphanCandidates.toSorted((a, b) => a.localeCompare(b)).slice(0, MAX_ORPHANS),\n stats: {\n total_files: fileMap.size,\n total_components: components.length,\n total_pages: pages.length,\n total_strings_estimate: totalStrings,\n },\n }\n}\n"],"mappings":"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"}
package/dist/index.mjs CHANGED
@@ -1,29 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  import "./contracts-DfL0BfrD.mjs";
3
- import "./reconcile-B55dZqhg.mjs";
4
- import "./context-BHfO4WBv.mjs";
5
- import "./model-manager-CfwJWvz7.mjs";
6
- import "./ops-B2P11b8v.mjs";
7
- import "./fs-DJv5G8uL.mjs";
8
- import "./meta-manager-Cd6AfuGS.mjs";
9
- import "./config-B8a4JBT1.mjs";
10
- import "./validator-BD0Z0O0_.mjs";
3
+ import "./reconcile-CPDGFQ8H.mjs";
4
+ import "./context-fJCKH_qv.mjs";
5
+ import "./model-manager-DPXulg7z.mjs";
6
+ import "./ops-DxhCCs_Z.mjs";
7
+ import "./fs-BFTuXbbw.mjs";
8
+ import "./meta-manager-CSpKtjnP.mjs";
9
+ import "./config-BLwZtwlI.mjs";
10
+ import "./validator-C08p1OpM.mjs";
11
11
  import "./identity-CxAB9Qb8.mjs";
12
- import "./branch-lifecycle-DUOoyNWn.mjs";
12
+ import "./branch-lifecycle-f4gI_eGo.mjs";
13
13
  import "./id-CjGMPP1k.mjs";
14
- import "./transaction-BR2jg4uj.mjs";
15
- import "./local-DgjDad1x.mjs";
16
- import "./detect-CI32ynpq.mjs";
14
+ import "./transaction-BWJt1KQb.mjs";
15
+ import "./local-DkDTrGt_.mjs";
16
+ import "./detect-DUYFv3-O.mjs";
17
17
  import "./annotations-CXHPnS-M.mjs";
18
- import { n as createServer } from "./server-CCn_fR9q.mjs";
18
+ import { n as createServer } from "./server-BRXP35b8.mjs";
19
19
  import "./errors-BmLE-JSC.mjs";
20
- import "./reconcile-CwS4LUbo.mjs";
21
- import "./scan-config-C5zzygeH.mjs";
22
- import "./graph-builder-C9NS7y-a.mjs";
23
- import "./scanner-CEiT8Tgx.mjs";
20
+ import "./reconcile-Cq60cldB.mjs";
21
+ import "./scan-config-DkRKu_ON.mjs";
22
+ import "./graph-builder-CNswWNtb.mjs";
23
+ import "./scanner-BLo3ssiF.mjs";
24
24
  import "./tsx-parser-go5V9zoc.mjs";
25
- import "./apply-manager-eZ6zA99Y.mjs";
26
- import "./doctor-VnOzDTbk.mjs";
25
+ import "./apply-manager-llmSyK5K.mjs";
26
+ import "./doctor-fIG4TtsO.mjs";
27
27
  import { resolve } from "node:path";
28
28
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
29
29
  //#region src/index.ts
@@ -1,14 +1,16 @@
1
1
  import { t as LOCAL_CAPABILITIES } from "./contracts-DfL0BfrD.mjs";
2
- import { n as applyChangesToWorktree } from "./reconcile-B55dZqhg.mjs";
3
- import { k as LocalReader } from "./model-manager-CfwJWvz7.mjs";
4
- import { t as readConfig } from "./config-B8a4JBT1.mjs";
2
+ import { n as applyChangesToWorktree } from "./reconcile-CPDGFQ8H.mjs";
3
+ import { o as canonicalStringify$1, s as sortKeys$1 } from "./context-fJCKH_qv.mjs";
4
+ import { k as LocalReader } from "./model-manager-DPXulg7z.mjs";
5
+ import { t as readConfig } from "./config-BLwZtwlI.mjs";
5
6
  import { r as createGit } from "./identity-CxAB9Qb8.mjs";
6
- import { n as checkBranchHealth, o as deleteRemoteBranch, r as classifyMergedBranches } from "./branch-lifecycle-DUOoyNWn.mjs";
7
- import { o as mergeBranch$1, r as createTransaction } from "./transaction-BR2jg4uj.mjs";
7
+ import { d as resolveBaseBranch, n as checkBranchHealth, o as deleteRemoteBranch, r as classifyMergedBranches } from "./branch-lifecycle-f4gI_eGo.mjs";
8
+ import { o as mergeBranch$1, r as createTransaction } from "./transaction-BWJt1KQb.mjs";
8
9
  import { join } from "node:path";
10
+ import { readFile } from "node:fs/promises";
9
11
  import { tmpdir } from "node:os";
10
12
  import { randomUUID } from "node:crypto";
11
- import { CONTENTRAIN_BRANCH } from "@contentrain/types";
13
+ import { CONTENTRAIN_BRANCH, MODEL_FIELD_ORDER } from "@contentrain/types";
12
14
  //#region src/providers/local/branch-ops.ts
13
15
  /**
14
16
  * Branch/merge/diff helpers backed by the local simple-git worktree flow.
@@ -18,11 +20,7 @@ import { CONTENTRAIN_BRANCH } from "@contentrain/types";
18
20
  * surface at the `RepoProvider` boundary.
19
21
  */
20
22
  async function getDefaultBranch(projectRoot) {
21
- const config = await readConfig(projectRoot);
22
- if (config?.repository?.default_branch) return config.repository.default_branch;
23
- const envBranch = process.env["CONTENTRAIN_BRANCH"];
24
- if (envBranch) return envBranch;
25
- return (await createGit(projectRoot).raw(["branch", "--show-current"])).trim() || "main";
23
+ return resolveBaseBranch(createGit(projectRoot), await readConfig(projectRoot));
26
24
  }
27
25
  async function listBranches(projectRoot, prefix) {
28
26
  const summary = await createGit(projectRoot).branchLocal();
@@ -93,6 +91,110 @@ async function isMerged(projectRoot, branch, into) {
93
91
  }
94
92
  }
95
93
  //#endregion
94
+ //#region src/providers/local/rebase-changes.ts
95
+ function rebaseChange(change, basis, current) {
96
+ if (current === basis || current === change.content) return {
97
+ ok: true,
98
+ change
99
+ };
100
+ const reject = (reason) => ({
101
+ ok: false,
102
+ path: change.path,
103
+ reason
104
+ });
105
+ if (basis !== null && current === null) return reject("the file was deleted on the contentrain branch since it was read");
106
+ if (!change.path.endsWith(".json")) return reject("the file changed on the contentrain branch since it was read, and only JSON files can be merged");
107
+ const base = basis === null ? {} : parseObject(basis);
108
+ const theirs = current === null ? {} : parseObject(current);
109
+ const ours = change.content === null ? {} : parseObject(change.content);
110
+ if (!base || !theirs || !ours) return reject("the file changed on the contentrain branch since it was read, and is not a JSON object");
111
+ const merged = merge3(base, ours, theirs, [], change.path.startsWith(".contentrain/meta/"));
112
+ if (!merged.ok) return reject(`${describeKey(merged.at)} was changed both by this write and on the contentrain branch`);
113
+ const value = merged.value === ABSENT ? {} : merged.value;
114
+ if (change.content === null) return Object.keys(value).length === 0 ? {
115
+ ok: true,
116
+ change
117
+ } : reject("the file gained entries on the contentrain branch since it was read, so deleting it would drop them");
118
+ const fieldOrder = change.path.startsWith(".contentrain/models/") ? MODEL_FIELD_ORDER : void 0;
119
+ return {
120
+ ok: true,
121
+ change: {
122
+ path: change.path,
123
+ content: canonicalStringify$1(value, fieldOrder)
124
+ }
125
+ };
126
+ }
127
+ const ABSENT = Symbol("absent");
128
+ /** Meta keys that record who wrote last and when — see the module comment. */
129
+ const WRITE_STAMP = new Set([
130
+ "updated_at",
131
+ "updated_by",
132
+ "source"
133
+ ]);
134
+ function merge3(base, ours, theirs, at, stampWins) {
135
+ if (same(ours, base)) return {
136
+ ok: true,
137
+ value: theirs
138
+ };
139
+ if (same(theirs, base)) return {
140
+ ok: true,
141
+ value: ours
142
+ };
143
+ if (same(ours, theirs)) return {
144
+ ok: true,
145
+ value: ours
146
+ };
147
+ if (!isPlainObject(base) || !isPlainObject(ours) || !isPlainObject(theirs)) return {
148
+ ok: false,
149
+ at
150
+ };
151
+ const restamped = stampWins && [...WRITE_STAMP].some((key) => !same(field(ours, key), field(base, key)));
152
+ const out = {};
153
+ const keys = new Set([
154
+ ...Object.keys(base),
155
+ ...Object.keys(ours),
156
+ ...Object.keys(theirs)
157
+ ]);
158
+ for (const key of keys) {
159
+ if (restamped && WRITE_STAMP.has(key)) {
160
+ const value = field(ours, key);
161
+ if (value !== ABSENT) out[key] = value;
162
+ continue;
163
+ }
164
+ const result = merge3(field(base, key), field(ours, key), field(theirs, key), [...at, key], stampWins);
165
+ if (!result.ok) return result;
166
+ if (result.value !== ABSENT) out[key] = result.value;
167
+ }
168
+ return {
169
+ ok: true,
170
+ value: out
171
+ };
172
+ }
173
+ function field(obj, key) {
174
+ return Object.hasOwn(obj, key) && obj[key] !== null && obj[key] !== void 0 ? obj[key] : ABSENT;
175
+ }
176
+ /** Canonical equality — the same normalisation the serializer applies (sorted keys, nulls dropped). */
177
+ function same(a, b) {
178
+ if (a === ABSENT || b === ABSENT) return a === b;
179
+ return JSON.stringify(sortKeys$1(a)) === JSON.stringify(sortKeys$1(b));
180
+ }
181
+ function isPlainObject(value) {
182
+ return typeof value === "object" && value !== null && !Array.isArray(value);
183
+ }
184
+ function parseObject(raw) {
185
+ try {
186
+ const parsed = JSON.parse(raw);
187
+ return isPlainObject(parsed) ? parsed : void 0;
188
+ } catch {
189
+ return;
190
+ }
191
+ }
192
+ function describeKey(at) {
193
+ if (at.length === 0) return "the file";
194
+ if (at.length === 1) return `"${at[0]}"`;
195
+ return `"${at[0]}" (field ${at.slice(1).map((k) => `"${k}"`).join(" → ")})`;
196
+ }
197
+ //#endregion
96
198
  //#region src/providers/local/provider.ts
97
199
  const DEFAULT_AUTHOR_NAME = "Contentrain";
98
200
  const DEFAULT_AUTHOR_EMAIL = "ai@contentrain.io";
@@ -140,10 +242,15 @@ var LocalProvider = class {
140
242
  } : { blocked: false };
141
243
  }
142
244
  async applyPlan(input) {
245
+ const basis = await Promise.all(input.changes.map((c) => readOrNull(join(this.projectRoot, c.path))));
143
246
  const tx = await createTransaction(this.projectRoot, input.branch, { workflowOverride: input.workflowOverride });
144
247
  try {
145
248
  await tx.write(async (wt) => {
146
- await applyChangesToWorktree(wt, input.changes);
249
+ const current = await Promise.all(input.changes.map((c) => readOrNull(join(wt, c.path))));
250
+ const results = input.changes.map((change, i) => rebaseChange(change, basis[i], current[i]));
251
+ const conflicts = results.filter((r) => !r.ok);
252
+ if (conflicts.length > 0) throw Object.assign(/* @__PURE__ */ new Error(`Nothing was written: the content branch moved since your working tree was last updated, and this change cannot be applied on top of it. ` + conflicts.map((c) => `${c.path}: ${c.reason}.`).join(" ")), staleTreeGuidance(tx.baseBranch, tx.baseCheckedOut));
253
+ await applyChangesToWorktree(wt, results.map((r) => r.change));
147
254
  });
148
255
  await tx.commit(input.message, input.context);
149
256
  const gitResult = await tx.complete();
@@ -260,7 +367,27 @@ var LocalProvider = class {
260
367
  }
261
368
  }
262
369
  };
370
+ async function readOrNull(path) {
371
+ try {
372
+ return await readFile(path, "utf-8");
373
+ } catch {
374
+ return null;
375
+ }
376
+ }
377
+ /**
378
+ * How to bring the planner's tree up to date. On the base branch that is a
379
+ * pull. On a feature branch the content never reaches the working tree (the
380
+ * advance leaves a non-base checkout alone), so a pull fetches nothing
381
+ * useful — merging the base is what brings it in.
382
+ */
383
+ function staleTreeGuidance(baseBranch, baseCheckedOut) {
384
+ return {
385
+ code: "CONTENT_WORKING_TREE_STALE",
386
+ agent_hint: `The content on the contentrain branch differs from the working tree this write was planned from — another writer (Studio, a teammate, CI) or an earlier write changed the same value. ${baseCheckedOut ? "Ask the developer to update their working tree (git pull)" : `The developer is on a branch other than "${baseBranch}", which content writes do not update. Ask them to merge "${baseBranch}" into it (or rebase onto it)`}, then re-read the content and retry — do not retry unchanged.`,
387
+ developer_action: baseCheckedOut ? "git pull" : `git merge ${baseBranch}`
388
+ };
389
+ }
263
390
  //#endregion
264
391
  export { isMerged as n, LocalProvider as t };
265
392
 
266
- //# sourceMappingURL=local-DgjDad1x.mjs.map
393
+ //# sourceMappingURL=local-DkDTrGt_.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-DkDTrGt_.mjs","names":["mergeBranchOp","canonicalStringify","sortKeys","listBranchesOp","createBranchOp","deleteBranchOp","getBranchDiffOp","mergeBranchOp","isMergedOp","getDefaultBranchOp"],"sources":["../src/providers/local/branch-ops.ts","../src/providers/local/rebase-changes.ts","../src/providers/local/provider.ts"],"sourcesContent":["import { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport { createGit } from '../../git/identity.js'\nimport type { Branch, FileDiff, MergeResult } from '../../core/contracts/index.js'\nimport { readConfig } from '../../core/config.js'\nimport { resolveBaseBranch } from '../../git/base-branch.js'\nimport { classifyMergedBranches, deleteRemoteBranch } from '../../git/branch-lifecycle.js'\nimport { mergeBranch as mergeBranchOp } from '../../git/transaction.js'\n\n/**\n * Branch/merge/diff helpers backed by the local simple-git worktree flow.\n *\n * Pure functions composed into `LocalProvider` — mirroring the shape of\n * `providers/github/branch-ops.ts` so the two providers share the same\n * surface at the `RepoProvider` boundary.\n */\n\nexport async function getDefaultBranch(projectRoot: string): Promise<string> {\n return resolveBaseBranch(createGit(projectRoot), await readConfig(projectRoot))\n}\n\nexport async function listBranches(\n projectRoot: string,\n prefix?: string,\n): Promise<Branch[]> {\n const git = createGit(projectRoot)\n const summary = await git.branchLocal()\n const names = prefix\n ? summary.all.filter(n => n.startsWith(prefix))\n : summary.all\n const branches: Branch[] = []\n for (const name of names) {\n const info = summary.branches[name]\n branches.push({ name, sha: info?.commit ?? '' })\n }\n return branches\n}\n\nexport async function createBranch(\n projectRoot: string,\n name: string,\n fromRef: string,\n): Promise<void> {\n const git = createGit(projectRoot)\n await git.raw(['branch', name, fromRef])\n}\n\nexport async function deleteBranch(\n projectRoot: string,\n name: string,\n): Promise<void> {\n const git = createGit(projectRoot)\n await git.deleteLocalBranch(name, true)\n // Parity with the remote-API providers, whose deleteBranch removes the\n // remote ref: best-effort, config-gated inside the helper, never throws.\n await deleteRemoteBranch(projectRoot, name)\n}\n\nexport async function getBranchDiff(\n projectRoot: string,\n branch: string,\n base: string,\n): Promise<FileDiff[]> {\n const git = createGit(projectRoot)\n const raw = await git.raw(['diff', '--name-status', `${base}...${branch}`])\n const diffs: FileDiff[] = []\n for (const line of raw.split('\\n')) {\n if (!line.trim()) continue\n const [code, ...pathParts] = line.split('\\t')\n const path = pathParts[pathParts.length - 1]\n if (!code || !path) continue\n const status: FileDiff['status'] = code.startsWith('A')\n ? 'added'\n : code.startsWith('D')\n ? 'removed'\n : 'modified'\n diffs.push({ path, status, before: null, after: null })\n }\n return diffs\n}\n\nexport async function mergeBranch(\n projectRoot: string,\n branch: string,\n into: string,\n): Promise<MergeResult> {\n if (into !== CONTENTRAIN_BRANCH) {\n throw Object.assign(new Error(\n `LocalProvider.mergeBranch only supports merging into \"${CONTENTRAIN_BRANCH}\" (got \"${into}\"). `\n + `The local flow merges feature branches into the content-tracking branch and fast-forwards the base branch via update-ref.`,\n ), {\n code: 'UNSUPPORTED_MERGE_TARGET',\n agent_hint: `Pass \"${CONTENTRAIN_BRANCH}\" as the merge target, or use a non-local provider that supports arbitrary targets.`,\n developer_action: `Merge \"${branch}\" into \"${CONTENTRAIN_BRANCH}\" instead.`,\n })\n }\n const result = await mergeBranchOp(projectRoot, branch)\n return {\n merged: true,\n sha: result.commit,\n pullRequestUrl: null,\n sync: result.sync,\n ...(result.remote ? { remote: result.remote } : {}),\n }\n}\n\nexport async function isMerged(\n projectRoot: string,\n branch: string,\n into: string,\n): Promise<boolean> {\n try {\n const merged = await classifyMergedBranches(projectRoot, [branch], into)\n return merged.has(branch)\n } catch {\n return false\n }\n}\n","import { MODEL_FIELD_ORDER } from '@contentrain/types'\nimport type { FileChange } from '../../core/contracts/index.js'\nimport { canonicalStringify, sortKeys } from '../../core/serialization/index.js'\n\n/**\n * Carry a plan's full-file changes over to the tree they are committed on.\n *\n * The local provider plans against the developer's working tree, but commits\n * in a worktree on the `contentrain` branch after fetching and merging the\n * remote. Those two trees differ whenever another writer (Studio, a teammate,\n * CI) reached `contentrain` since the developer last pulled. A plan is a set\n * of whole files, so writing it as-is replaced every file it touched with a\n * copy built from the stale tree — silently deleting whatever the other\n * writer had added (#226).\n *\n * Each change is a three-way merge instead:\n * basis — the file as the planner read it (working tree, `null` if absent)\n * planned — the change's content (`null` for a delete)\n * current — the file in the worktree the commit is built on\n *\n * `current === basis` is the common case and writes the plan verbatim. When\n * they differ, JSON object files (collection / dictionary / singleton content,\n * meta, model definitions) are merged key by key: what the plan changed\n * relative to its basis is applied on top of `current`, recursively through\n * nested objects. Anything that cannot be merged that way — both sides\n * changed the same value, a markdown document, a delete of a file that\n * gained entries — is a conflict. Conflicts are never resolved by guessing:\n * the caller refuses the write.\n *\n * One exception, in meta files only: the write stamp (`updated_at`,\n * `updated_by`, `source`). Every write re-stamps the entries it touches\n * (`mergeEntryMeta`, `applyStatusChange`), so two writes to the same entry\n * always disagree there. The stamp describes the latest write, which is this\n * one — so when this write re-stamped an entry, its whole stamp wins, as a\n * unit (never this write's `updated_at` with the other writer's\n * `updated_by`). `status` and scheduling still merge or conflict normally.\n */\nexport type RebaseResult =\n | { ok: true, change: FileChange }\n | { ok: false, path: string, reason: string }\n\nexport function rebaseChange(change: FileChange, basis: string | null, current: string | null): RebaseResult {\n if (current === basis || current === change.content) return { ok: true, change }\n\n const reject = (reason: string): RebaseResult => ({ ok: false, path: change.path, reason })\n\n if (basis !== null && current === null) {\n return reject('the file was deleted on the contentrain branch since it was read')\n }\n if (!change.path.endsWith('.json')) {\n return reject('the file changed on the contentrain branch since it was read, and only JSON files can be merged')\n }\n\n const base = basis === null ? {} : parseObject(basis)\n const theirs = current === null ? {} : parseObject(current)\n const ours = change.content === null ? {} : parseObject(change.content)\n if (!base || !theirs || !ours) {\n return reject('the file changed on the contentrain branch since it was read, and is not a JSON object')\n }\n\n const stampWins = change.path.startsWith('.contentrain/meta/')\n const merged = merge3(base, ours, theirs, [], stampWins)\n if (!merged.ok) {\n return reject(`${describeKey(merged.at)} was changed both by this write and on the contentrain branch`)\n }\n const value = merged.value === ABSENT ? {} : merged.value as Record<string, unknown>\n\n if (change.content === null) {\n // A delete merges to \"the keys only the other writer has\". Writing those\n // back would keep a file this operation meant to remove (a deleted model's\n // content, say) — refuse instead and let the caller decide after a pull.\n return Object.keys(value).length === 0\n ? { ok: true, change }\n : reject('the file gained entries on the contentrain branch since it was read, so deleting it would drop them')\n }\n\n const fieldOrder = change.path.startsWith('.contentrain/models/') ? MODEL_FIELD_ORDER : undefined\n return { ok: true, change: { path: change.path, content: canonicalStringify(value, fieldOrder) } }\n}\n\nconst ABSENT = Symbol('absent')\ntype Value = unknown | typeof ABSENT\n\ntype MergeResult = { ok: true, value: Value } | { ok: false, at: string[] }\n\n/** Meta keys that record who wrote last and when — see the module comment. */\nconst WRITE_STAMP = new Set(['updated_at', 'updated_by', 'source'])\n\nfunction merge3(base: Value, ours: Value, theirs: Value, at: string[], stampWins: boolean): MergeResult {\n if (same(ours, base)) return { ok: true, value: theirs }\n if (same(theirs, base)) return { ok: true, value: ours }\n if (same(ours, theirs)) return { ok: true, value: ours }\n if (!isPlainObject(base) || !isPlainObject(ours) || !isPlainObject(theirs)) {\n return { ok: false, at }\n }\n\n const restamped = stampWins\n && [...WRITE_STAMP].some(key => !same(field(ours, key), field(base, key)))\n const out: Record<string, unknown> = {}\n const keys = new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])\n for (const key of keys) {\n if (restamped && WRITE_STAMP.has(key)) {\n const value = field(ours, key)\n if (value !== ABSENT) out[key] = value\n continue\n }\n const result = merge3(field(base, key), field(ours, key), field(theirs, key), [...at, key], stampWins)\n if (!result.ok) return result\n if (result.value !== ABSENT) out[key] = result.value\n }\n return { ok: true, value: out }\n}\n\nfunction field(obj: Record<string, unknown>, key: string): Value {\n return Object.hasOwn(obj, key) && obj[key] !== null && obj[key] !== undefined ? obj[key] : ABSENT\n}\n\n/** Canonical equality — the same normalisation the serializer applies (sorted keys, nulls dropped). */\nfunction same(a: Value, b: Value): boolean {\n if (a === ABSENT || b === ABSENT) return a === b\n return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b))\n}\n\nfunction isPlainObject(value: Value): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction parseObject(raw: string): Record<string, unknown> | undefined {\n try {\n const parsed: unknown = JSON.parse(raw)\n return isPlainObject(parsed) ? parsed : undefined\n } catch {\n return undefined\n }\n}\n\nfunction describeKey(at: string[]): string {\n if (at.length === 0) return 'the file'\n if (at.length === 1) return `\"${at[0]}\"`\n return `\"${at[0]}\" (field ${at.slice(1).map(k => `\"${k}\"`).join(' → ')})`\n}\n","import { readFile } from 'node:fs/promises'\nimport { join } from 'node:path'\nimport { tmpdir } from 'node:os'\nimport { randomUUID } from 'node:crypto'\nimport { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type {\n Branch,\n Commit,\n CommitAuthor,\n FileChange,\n FileDiff,\n MergeResult,\n ProviderCapabilities,\n RepoProvider,\n WriteReadiness,\n} from '../../core/contracts/index.js'\nimport { LOCAL_CAPABILITIES } from '../../core/contracts/index.js'\nimport { applyChangesToWorktree } from '../../core/ops/index.js'\nimport { checkBranchHealth } from '../../git/branch-lifecycle.js'\nimport { createGit } from '../../git/identity.js'\nimport { createTransaction } from '../../git/transaction.js'\nimport {\n createBranch as createBranchOp,\n deleteBranch as deleteBranchOp,\n getBranchDiff as getBranchDiffOp,\n getDefaultBranch as getDefaultBranchOp,\n isMerged as isMergedOp,\n listBranches as listBranchesOp,\n mergeBranch as mergeBranchOp,\n} from './branch-ops.js'\nimport { LocalReader } from './reader.js'\nimport { rebaseChange } from './rebase-changes.js'\nimport type { LocalApplyPlanInput, LocalApplyResult } from './types.js'\n\nconst DEFAULT_AUTHOR_NAME = 'Contentrain'\nconst DEFAULT_AUTHOR_EMAIL = 'ai@contentrain.io'\n\n/**\n * LocalProvider — the local-filesystem, worktree-backed content provider.\n *\n * Implements the full `RepoProvider` surface:\n * - Reader methods delegate to `LocalReader`.\n * - `applyPlan` wraps `createTransaction` and returns `LocalApplyResult`\n * (a superset of `Commit` carrying workflow action + selective sync).\n * - Branch ops mirror `GitHubProvider` — thin wrappers over the local\n * simple-git helpers in `./branch-ops.ts`.\n *\n * `mergeBranch` only supports merging into the singleton\n * `CONTENTRAIN_BRANCH`; the local flow advances the base branch via\n * `update-ref` in `transaction.mergeBranch`, so arbitrary merge targets\n * would bypass that invariant.\n */\nexport class LocalProvider implements RepoProvider {\n readonly capabilities: ProviderCapabilities = LOCAL_CAPABILITIES\n private readonly reader: LocalReader\n\n constructor(public readonly projectRoot: string) {\n this.reader = new LocalReader(projectRoot)\n }\n\n readFile(path: string, ref?: string): Promise<string> {\n return this.reader.readFile(path, ref)\n }\n\n listDirectory(path: string, ref?: string): Promise<string[]> {\n return this.reader.listDirectory(path, ref)\n }\n\n fileExists(path: string, ref?: string): Promise<boolean> {\n return this.reader.fileExists(path, ref)\n }\n\n /**\n * Branch pressure gate. Unmerged `cr/*` branches accumulate in a local repo\n * and eventually have to stop new writes; hosted providers have no such\n * limit, so they omit this method entirely.\n */\n async checkWriteReadiness(): Promise<WriteReadiness> {\n const health = await checkBranchHealth(this.projectRoot)\n return health.blocked\n ? { blocked: true, message: health.message ?? 'Too many active contentrain branches.' }\n : { blocked: false }\n }\n\n async applyPlan(input: LocalApplyPlanInput): Promise<LocalApplyResult> {\n // What the planner read: every read goes through LocalReader, i.e. the\n // working tree. Captured before the transaction fetches, so it is the\n // tree the plan was computed from — not whatever the remote says now.\n const basis = await Promise.all(input.changes.map(c => readOrNull(join(this.projectRoot, c.path))))\n const tx = await createTransaction(this.projectRoot, input.branch, {\n workflowOverride: input.workflowOverride,\n })\n try {\n await tx.write(async (wt) => {\n // The worktree is the contentrain tip after fetch + merge — the tree\n // this commit is built on. Carry the plan over to it instead of\n // overwriting it with files built from a possibly stale working tree\n // (#226). See rebaseChange.\n const current = await Promise.all(input.changes.map(c => readOrNull(join(wt, c.path))))\n const results = input.changes.map((change, i) => rebaseChange(change, basis[i]!, current[i]!))\n const conflicts = results.filter(r => !r.ok)\n if (conflicts.length > 0) {\n throw Object.assign(new Error(\n `Nothing was written: the content branch moved since your working tree was last updated, and this change cannot be applied on top of it. `\n + conflicts.map(c => `${c.path}: ${c.reason}.`).join(' '),\n ), staleTreeGuidance(tx.baseBranch, tx.baseCheckedOut))\n }\n await applyChangesToWorktree(wt, results.map(r => (r as Extract<typeof r, { ok: true }>).change))\n })\n await tx.commit(input.message, input.context)\n const gitResult = await tx.complete()\n return {\n sha: gitResult.commit,\n message: input.message,\n author: input.author ?? {\n name: process.env['CONTENTRAIN_AUTHOR_NAME'] ?? DEFAULT_AUTHOR_NAME,\n email: process.env['CONTENTRAIN_AUTHOR_EMAIL'] ?? DEFAULT_AUTHOR_EMAIL,\n },\n timestamp: new Date().toISOString(),\n workflowAction: gitResult.action,\n sync: gitResult.sync,\n warning: gitResult.warning,\n base_advance: gitResult.base_advance,\n remote_push: gitResult.remote_push,\n }\n } finally {\n await tx.cleanup()\n }\n }\n\n listBranches(prefix?: string): Promise<Branch[]> {\n return listBranchesOp(this.projectRoot, prefix)\n }\n\n async createBranch(name: string, fromRef?: string): Promise<void> {\n const resolved = fromRef ?? CONTENTRAIN_BRANCH\n await createBranchOp(this.projectRoot, name, resolved)\n }\n\n deleteBranch(name: string): Promise<void> {\n return deleteBranchOp(this.projectRoot, name)\n }\n\n getBranchDiff(branch: string, base?: string): Promise<FileDiff[]> {\n const resolved = base ?? CONTENTRAIN_BRANCH\n return getBranchDiffOp(this.projectRoot, branch, resolved)\n }\n\n mergeBranch(branch: string, into: string): Promise<MergeResult> {\n return mergeBranchOp(this.projectRoot, branch, into)\n }\n\n isMerged(branch: string, into?: string): Promise<boolean> {\n const resolved = into ?? CONTENTRAIN_BRANCH\n return isMergedOp(this.projectRoot, branch, resolved)\n }\n\n getDefaultBranch(): Promise<string> {\n return getDefaultBranchOp(this.projectRoot)\n }\n\n async getMergeBase(refA: string, refB: string): Promise<string | null> {\n const git = createGit(this.projectRoot)\n try {\n return (await git.raw(['merge-base', refA, refB])).trim() || null\n } catch {\n return null\n }\n }\n\n /**\n * Two-parent merge commit in a temp worktree. The real `git merge`\n * resolves everything outside the plan; the plan's changes then\n * overwrite every content-owned path (planner output is authoritative,\n * never git's textual auto-merge). Leftover unmerged paths abort — they\n * are outside the content planner's scope by construction.\n */\n async createMergeCommit(input: {\n branch: string\n ours: string\n theirs: string\n changes: FileChange[]\n message: string\n author: CommitAuthor\n }): Promise<Commit> {\n const git = createGit(this.projectRoot)\n const tip = (await git.raw(['rev-parse', input.branch])).trim()\n if (tip !== input.ours) {\n throw Object.assign(new Error(\n `createMergeCommit: \"${input.branch}\" is at ${tip.slice(0, 8)}, not the expected ${input.ours.slice(0, 8)} — the branch moved since planning.`,\n ), {\n code: 'RECONCILE_STALE_OURS',\n agent_hint: 'Re-run the reconcile plan against the current tip.',\n })\n }\n\n const worktreePath = join(tmpdir(), `cr-merge-commit-${randomUUID()}`)\n await git.raw(['worktree', 'add', worktreePath, input.branch])\n const wtGit = createGit(worktreePath, {\n config: [\n `user.name=${input.author.name}`,\n `user.email=${input.author.email}`,\n ],\n })\n try {\n try {\n await wtGit.merge([input.theirs, '--no-commit', '--no-ff'])\n } catch {\n // Conflicts are expected — the plan overwrites content paths next.\n }\n await applyChangesToWorktree(worktreePath, input.changes)\n await wtGit.raw(['add', '-A'])\n const unmerged = (await wtGit.raw(['ls-files', '-u'])).trim()\n if (unmerged) {\n const paths = [...new Set(unmerged.split('\\n').map(l => l.split('\\t')[1]).filter(Boolean))]\n try { await wtGit.merge(['--abort']) } catch { /* not in merge state */ }\n throw Object.assign(new Error(\n `createMergeCommit: ${paths.length} non-content file(s) conflict: ${paths.join(', ')}.`,\n ), { code: 'RECONCILE_SOURCE_CONFLICT' })\n }\n await wtGit.commit(input.message, { '--no-verify': null })\n const sha = (await wtGit.raw(['rev-parse', 'HEAD'])).trim()\n return {\n sha,\n message: input.message,\n author: input.author,\n timestamp: new Date().toISOString(),\n }\n } finally {\n try {\n await git.raw(['worktree', 'remove', worktreePath, '--force'])\n } catch {\n // worktree may already be cleaned up\n }\n }\n }\n}\n\nasync function readOrNull(path: string): Promise<string | null> {\n try {\n return await readFile(path, 'utf-8')\n } catch {\n return null\n }\n}\n\n/**\n * How to bring the planner's tree up to date. On the base branch that is a\n * pull. On a feature branch the content never reaches the working tree (the\n * advance leaves a non-base checkout alone), so a pull fetches nothing\n * useful — merging the base is what brings it in.\n */\nfunction staleTreeGuidance(baseBranch: string, baseCheckedOut: boolean): {\n code: string\n agent_hint: string\n developer_action: string\n} {\n const update = baseCheckedOut\n ? 'Ask the developer to update their working tree (git pull)'\n : `The developer is on a branch other than \"${baseBranch}\", which content writes do not update. Ask them to merge \"${baseBranch}\" into it (or rebase onto it)`\n return {\n code: 'CONTENT_WORKING_TREE_STALE',\n agent_hint: `The content on the contentrain branch differs from the working tree this write was planned from — another writer (Studio, a teammate, CI) or an earlier write changed the same value. ${update}, then re-read the content and retry — do not retry unchanged.`,\n developer_action: baseCheckedOut ? 'git pull' : `git merge ${baseBranch}`,\n }\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { a as readJson, s as writeJson, t as contentrainDir } from "./fs-DJv5G8uL.mjs";
1
+ import { a as readJson, s as writeJson, t as contentrainDir } from "./fs-BFTuXbbw.mjs";
2
2
  import { join } from "node:path";
3
3
  import { rm } from "node:fs/promises";
4
4
  //#region src/core/meta-manager.ts
@@ -127,4 +127,4 @@ async function deleteMeta(projectRoot, model, opts) {
127
127
  //#endregion
128
128
  export { writeMeta as a, readMeta as i, deleteMeta as n, writeMetaEntries as o, mergeEntryMeta as r, applyStatusChange as t };
129
129
 
130
- //# sourceMappingURL=meta-manager-Cd6AfuGS.mjs.map
130
+ //# sourceMappingURL=meta-manager-CSpKtjnP.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"meta-manager-Cd6AfuGS.mjs","names":[],"sources":["../src/core/meta-manager.ts"],"sourcesContent":["import type { ContentStatus, ModelDefinition, EntryMeta } from '@contentrain/types'\nimport { join } from 'node:path'\nimport { rm } from 'node:fs/promises'\nimport { contentrainDir, readJson, writeJson } from '../util/fs.js'\n\ninterface MetaOpts {\n locale?: string\n entryId?: string\n slug?: string\n /**\n * `config.locales.default`. Required: it places a non-i18n model's single\n * meta record, and stands in for an omitted locale on i18n models — the old\n * hardcoded `'en'` fallback silently wrote to the wrong file on projects\n * whose default was anything else.\n */\n defaultLocale: string\n}\n\n/**\n * Absolute meta path. Non-i18n models keep exactly one meta record, pinned to\n * the default locale — see `ops/paths.ts:metaFilePath` for the full rationale.\n */\nfunction metaPath(projectRoot: string, model: ModelDefinition, opts: MetaOpts): string {\n const base = join(contentrainDir(projectRoot), 'meta', model.id)\n const effective = model.i18n ? (opts.locale ?? opts.defaultLocale) : opts.defaultLocale\n\n if (model.kind === 'document' && opts.slug) {\n return join(base, opts.slug, `${effective}.json`)\n }\n\n return join(base, `${effective}.json`)\n}\n\nexport async function readMeta(\n projectRoot: string,\n model: ModelDefinition,\n opts: MetaOpts,\n): Promise<EntryMeta | Record<string, EntryMeta> | null> {\n return readJson(metaPath(projectRoot, model, opts))\n}\n\n/**\n * Build the meta for a content write, merged over the entry's existing meta.\n *\n * `status` is a publish decision and belongs to whoever made it — editing a\n * field must not unpublish an entry. Only an entry with no meta yet starts at\n * `draft`. `source`/`updated_by`/`updated_at` describe *this* write, so they\n * are stamped every time; `approved_by`/`version` survive via the spread.\n *\n * Pass `existing: undefined` to mint meta for a genuinely new entry.\n */\n/**\n * The write timestamp. One function so there is one answer to \"when\", and so\n * a test can freeze it in one place.\n */\nfunction nowIso(): string {\n return new Date().toISOString()\n}\n\n/**\n * Meta for a deliberate status change.\n *\n * Distinct from {@link mergeEntryMeta}, which preserves `status` on purpose —\n * editing a field must not unpublish an entry. Changing the status IS the\n * operation here, so it is the one place that may overwrite it. Everything\n * else about the write is stamped identically.\n */\nexport function applyStatusChange(existing: EntryMeta, status: ContentStatus): EntryMeta {\n return {\n ...existing,\n status,\n updated_by: 'contentrain-mcp',\n updated_at: nowIso(),\n }\n}\n\n/**\n * Scheduling a write may carry. Three states per key, and the difference is\n * the whole contract: a string sets the date, `null` clears it, and an absent\n * key leaves whatever is in meta alone — the same \"omitted means not editing\"\n * rule a document's `body` follows.\n *\n * Scheduling is meta and only meta. It never reaches the content file, and it\n * never changes `status`: a `publish_at` in the past does not publish a draft.\n * Delivery treats the date as a gate on top of `status: published` — the CDN\n * serves a published entry once `publish_at` has passed and until `expire_at`.\n * Publishing stays a deliberate `contentrain_bulk update_status` call (#125).\n */\nexport interface EntrySchedule {\n publish_at?: string | null\n expire_at?: string | null\n}\n\nexport function mergeEntryMeta(\n existing: EntryMeta | undefined,\n schedule?: EntrySchedule,\n): EntryMeta {\n const meta: EntryMeta = {\n ...existing,\n status: existing?.status ?? 'draft',\n source: 'agent',\n updated_by: 'contentrain-mcp',\n updated_at: nowIso(),\n }\n applySchedule(meta, 'publish_at', schedule?.publish_at)\n applySchedule(meta, 'expire_at', schedule?.expire_at)\n return meta\n}\n\nfunction applySchedule(meta: EntryMeta, key: keyof EntrySchedule, value: string | null | undefined): void {\n if (value === undefined) return\n if (value === null) {\n delete meta[key]\n return\n }\n meta[key] = value\n}\n\nexport async function writeMeta(\n projectRoot: string,\n model: ModelDefinition,\n opts: MetaOpts,\n meta: EntryMeta,\n): Promise<void> {\n const filePath = metaPath(projectRoot, model, opts)\n\n if (model.kind === 'collection' && opts.entryId) {\n const existing = await readJson<Record<string, EntryMeta>>(filePath) ?? {}\n existing[opts.entryId] = meta\n await writeJson(filePath, existing)\n } else {\n await writeJson(filePath, meta)\n }\n}\n\n/**\n * Apply many entry-meta updates to one collection locale file in a single\n * read-modify-write, returning the entry IDs actually written.\n *\n * Do NOT loop `writeMeta` over entry IDs: every call rewrites the whole shared\n * `{locale}.json`, so concurrent calls each start from the same snapshot and\n * all but the last-settling one are silently lost. This function exists so that\n * race cannot be reintroduced.\n */\nexport async function writeMetaEntries(\n projectRoot: string,\n model: ModelDefinition,\n opts: MetaOpts,\n updates: Record<string, EntryMeta>,\n): Promise<string[]> {\n const ids = Object.keys(updates)\n if (ids.length === 0) return []\n\n const filePath = metaPath(projectRoot, model, opts)\n const existing = await readJson<Record<string, EntryMeta>>(filePath) ?? {}\n await writeJson(filePath, { ...existing, ...updates })\n return ids\n}\n\nexport async function deleteMeta(\n projectRoot: string,\n model: ModelDefinition,\n opts: MetaOpts,\n): Promise<void> {\n if (model.kind === 'collection' && opts.entryId) {\n const filePath = metaPath(projectRoot, model, opts)\n const existing = await readJson<Record<string, EntryMeta>>(filePath)\n if (existing && opts.entryId in existing) {\n delete existing[opts.entryId]\n if (Object.keys(existing).length > 0) {\n await writeJson(filePath, existing)\n } else {\n await rm(filePath, { force: true })\n }\n }\n } else if (model.kind === 'document' && opts.slug) {\n const slugDir = join(contentrainDir(projectRoot), 'meta', model.id, opts.slug)\n if (opts.locale) {\n await rm(join(slugDir, `${opts.locale}.json`), { force: true })\n } else {\n try {\n await rm(slugDir, { recursive: true, force: true })\n } catch {\n // directory may not exist\n }\n }\n } else if (opts.locale) {\n const filePath = metaPath(projectRoot, model, opts)\n await rm(filePath, { force: true })\n } else {\n // No locale specified — remove all meta for this model (non-i18n or full cleanup)\n const metaDir = join(contentrainDir(projectRoot), 'meta', model.id)\n try {\n await rm(metaDir, { recursive: true, force: true })\n } catch {\n // directory may not exist\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"meta-manager-CSpKtjnP.mjs","names":[],"sources":["../src/core/meta-manager.ts"],"sourcesContent":["import type { ContentStatus, ModelDefinition, EntryMeta } from '@contentrain/types'\nimport { join } from 'node:path'\nimport { rm } from 'node:fs/promises'\nimport { contentrainDir, readJson, writeJson } from '../util/fs.js'\n\ninterface MetaOpts {\n locale?: string\n entryId?: string\n slug?: string\n /**\n * `config.locales.default`. Required: it places a non-i18n model's single\n * meta record, and stands in for an omitted locale on i18n models — the old\n * hardcoded `'en'` fallback silently wrote to the wrong file on projects\n * whose default was anything else.\n */\n defaultLocale: string\n}\n\n/**\n * Absolute meta path. Non-i18n models keep exactly one meta record, pinned to\n * the default locale — see `ops/paths.ts:metaFilePath` for the full rationale.\n */\nfunction metaPath(projectRoot: string, model: ModelDefinition, opts: MetaOpts): string {\n const base = join(contentrainDir(projectRoot), 'meta', model.id)\n const effective = model.i18n ? (opts.locale ?? opts.defaultLocale) : opts.defaultLocale\n\n if (model.kind === 'document' && opts.slug) {\n return join(base, opts.slug, `${effective}.json`)\n }\n\n return join(base, `${effective}.json`)\n}\n\nexport async function readMeta(\n projectRoot: string,\n model: ModelDefinition,\n opts: MetaOpts,\n): Promise<EntryMeta | Record<string, EntryMeta> | null> {\n return readJson(metaPath(projectRoot, model, opts))\n}\n\n/**\n * Build the meta for a content write, merged over the entry's existing meta.\n *\n * `status` is a publish decision and belongs to whoever made it — editing a\n * field must not unpublish an entry. Only an entry with no meta yet starts at\n * `draft`. `source`/`updated_by`/`updated_at` describe *this* write, so they\n * are stamped every time; `approved_by`/`version` survive via the spread.\n *\n * Pass `existing: undefined` to mint meta for a genuinely new entry.\n */\n/**\n * The write timestamp. One function so there is one answer to \"when\", and so\n * a test can freeze it in one place.\n */\nfunction nowIso(): string {\n return new Date().toISOString()\n}\n\n/**\n * Meta for a deliberate status change.\n *\n * Distinct from {@link mergeEntryMeta}, which preserves `status` on purpose —\n * editing a field must not unpublish an entry. Changing the status IS the\n * operation here, so it is the one place that may overwrite it. Everything\n * else about the write is stamped identically.\n */\nexport function applyStatusChange(existing: EntryMeta, status: ContentStatus): EntryMeta {\n return {\n ...existing,\n status,\n updated_by: 'contentrain-mcp',\n updated_at: nowIso(),\n }\n}\n\n/**\n * Scheduling a write may carry. Three states per key, and the difference is\n * the whole contract: a string sets the date, `null` clears it, and an absent\n * key leaves whatever is in meta alone — the same \"omitted means not editing\"\n * rule a document's `body` follows.\n *\n * Scheduling is meta and only meta. It never reaches the content file, and it\n * never changes `status`: a `publish_at` in the past does not publish a draft.\n * Delivery treats the date as a gate on top of `status: published` — the CDN\n * serves a published entry once `publish_at` has passed and until `expire_at`.\n * Publishing stays a deliberate `contentrain_bulk update_status` call (#125).\n */\nexport interface EntrySchedule {\n publish_at?: string | null\n expire_at?: string | null\n}\n\nexport function mergeEntryMeta(\n existing: EntryMeta | undefined,\n schedule?: EntrySchedule,\n): EntryMeta {\n const meta: EntryMeta = {\n ...existing,\n status: existing?.status ?? 'draft',\n source: 'agent',\n updated_by: 'contentrain-mcp',\n updated_at: nowIso(),\n }\n applySchedule(meta, 'publish_at', schedule?.publish_at)\n applySchedule(meta, 'expire_at', schedule?.expire_at)\n return meta\n}\n\nfunction applySchedule(meta: EntryMeta, key: keyof EntrySchedule, value: string | null | undefined): void {\n if (value === undefined) return\n if (value === null) {\n delete meta[key]\n return\n }\n meta[key] = value\n}\n\nexport async function writeMeta(\n projectRoot: string,\n model: ModelDefinition,\n opts: MetaOpts,\n meta: EntryMeta,\n): Promise<void> {\n const filePath = metaPath(projectRoot, model, opts)\n\n if (model.kind === 'collection' && opts.entryId) {\n const existing = await readJson<Record<string, EntryMeta>>(filePath) ?? {}\n existing[opts.entryId] = meta\n await writeJson(filePath, existing)\n } else {\n await writeJson(filePath, meta)\n }\n}\n\n/**\n * Apply many entry-meta updates to one collection locale file in a single\n * read-modify-write, returning the entry IDs actually written.\n *\n * Do NOT loop `writeMeta` over entry IDs: every call rewrites the whole shared\n * `{locale}.json`, so concurrent calls each start from the same snapshot and\n * all but the last-settling one are silently lost. This function exists so that\n * race cannot be reintroduced.\n */\nexport async function writeMetaEntries(\n projectRoot: string,\n model: ModelDefinition,\n opts: MetaOpts,\n updates: Record<string, EntryMeta>,\n): Promise<string[]> {\n const ids = Object.keys(updates)\n if (ids.length === 0) return []\n\n const filePath = metaPath(projectRoot, model, opts)\n const existing = await readJson<Record<string, EntryMeta>>(filePath) ?? {}\n await writeJson(filePath, { ...existing, ...updates })\n return ids\n}\n\nexport async function deleteMeta(\n projectRoot: string,\n model: ModelDefinition,\n opts: MetaOpts,\n): Promise<void> {\n if (model.kind === 'collection' && opts.entryId) {\n const filePath = metaPath(projectRoot, model, opts)\n const existing = await readJson<Record<string, EntryMeta>>(filePath)\n if (existing && opts.entryId in existing) {\n delete existing[opts.entryId]\n if (Object.keys(existing).length > 0) {\n await writeJson(filePath, existing)\n } else {\n await rm(filePath, { force: true })\n }\n }\n } else if (model.kind === 'document' && opts.slug) {\n const slugDir = join(contentrainDir(projectRoot), 'meta', model.id, opts.slug)\n if (opts.locale) {\n await rm(join(slugDir, `${opts.locale}.json`), { force: true })\n } else {\n try {\n await rm(slugDir, { recursive: true, force: true })\n } catch {\n // directory may not exist\n }\n }\n } else if (opts.locale) {\n const filePath = metaPath(projectRoot, model, opts)\n await rm(filePath, { force: true })\n } else {\n // No locale specified — remove all meta for this model (non-i18n or full cleanup)\n const metaDir = join(contentrainDir(projectRoot), 'meta', model.id)\n try {\n await rm(metaDir, { recursive: true, force: true })\n } catch {\n // directory may not exist\n }\n }\n}\n"],"mappings":"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"}
@@ -1,8 +1,8 @@
1
- import { a as readJson, c as writeText, i as readDir, n as ensureDir, o as readText, s as writeJson, t as contentrainDir } from "./fs-DJv5G8uL.mjs";
2
- import { a as writeMeta, i as readMeta, n as deleteMeta, r as mergeEntryMeta } from "./meta-manager-Cd6AfuGS.mjs";
1
+ import { a as readJson, c as writeText, i as readDir, n as ensureDir, o as readText, s as writeJson, t as contentrainDir } from "./fs-BFTuXbbw.mjs";
2
+ import { a as writeMeta, i as readMeta, n as deleteMeta, r as mergeEntryMeta } from "./meta-manager-CSpKtjnP.mjs";
3
3
  import { join, resolve } from "node:path";
4
- import { DICTIONARY_TITLE_FIELD, LOCALE_PATTERN, MODEL_FIELD_ORDER, MODEL_FIELD_ORDER as MODEL_FIELD_ORDER$1, TITLE_FIELD_TYPES, generateEntryId, isTitleFieldType, parseMarkdownFrontmatter, parseMarkdownFrontmatter as parseFrontmatter, serializeMarkdownFrontmatter, serializeMarkdownFrontmatter as serializeFrontmatter, validateEntryId as validateEntryId$1, validateLocale as validateLocale$1, validateSlug as validateSlug$1 } from "@contentrain/types";
5
4
  import { access, readFile, readdir, rm } from "node:fs/promises";
5
+ import { DICTIONARY_TITLE_FIELD, LOCALE_PATTERN, MODEL_FIELD_ORDER, MODEL_FIELD_ORDER as MODEL_FIELD_ORDER$1, TITLE_FIELD_TYPES, generateEntryId, isTitleFieldType, parseMarkdownFrontmatter, parseMarkdownFrontmatter as parseFrontmatter, serializeMarkdownFrontmatter, serializeMarkdownFrontmatter as serializeFrontmatter, validateEntryId as validateEntryId$1, validateLocale as validateLocale$1, validateSlug as validateSlug$1 } from "@contentrain/types";
6
6
  import { z } from "zod";
7
7
  //#region src/core/ops/paths.ts
8
8
  /**
@@ -1439,4 +1439,4 @@ function validateModelDefinition(input, opts = {}) {
1439
1439
  //#endregion
1440
1440
  export { contentDirPath as A, resolveMdFilePath as C, validateSlug$1 as D, validateLocale$1 as E, documentFilePath as M, metaFilePath as N, writeContent as O, resolveLocaleStrategy as S, validateEntryId$1 as T, listContent as _, countEntries as a, resolveContentDir as b, inferTitleField as c, readModel as d, titleFieldIssues as f, deleteContent as g, writeModel as h, collectFieldPaths as i, contentFilePath as j, LocalReader as k, legacyFieldNames as l, validateModelDefinition as m, MODEL_FIELD_ORDER$1 as n, deleteModel as o, titleFieldOptions as p, checkReferences as r, fieldDefZodSchema as s, FIELD_TYPE_ENUM as t, listModels as u, parseFrontmatter as v, serializeFrontmatter as w, resolveJsonFilePath as x, readContent as y };
1441
1441
 
1442
- //# sourceMappingURL=model-manager-CfwJWvz7.mjs.map
1442
+ //# sourceMappingURL=model-manager-DPXulg7z.mjs.map