canopycms 0.0.54 → 0.0.55

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 (205) hide show
  1. package/dist/api/assets.d.ts +14 -2
  2. package/dist/api/assets.d.ts.map +1 -1
  3. package/dist/api/assets.js +14 -3
  4. package/dist/api/assets.js.map +1 -1
  5. package/dist/api/branch-status.d.ts.map +1 -1
  6. package/dist/api/branch-status.js +8 -2
  7. package/dist/api/branch-status.js.map +1 -1
  8. package/dist/api/client.d.ts +2 -2
  9. package/dist/api/client.d.ts.map +1 -1
  10. package/dist/api/client.js +29 -14
  11. package/dist/api/client.js.map +1 -1
  12. package/dist/api/content.d.ts +2 -2
  13. package/dist/api/content.d.ts.map +1 -1
  14. package/dist/api/content.js +160 -26
  15. package/dist/api/content.js.map +1 -1
  16. package/dist/api/entries.d.ts.map +1 -1
  17. package/dist/api/entries.js +8 -4
  18. package/dist/api/entries.js.map +1 -1
  19. package/dist/api/github-sync.d.ts.map +1 -1
  20. package/dist/api/github-sync.js +21 -4
  21. package/dist/api/github-sync.js.map +1 -1
  22. package/dist/api/groups.d.ts.map +1 -1
  23. package/dist/api/groups.js +15 -5
  24. package/dist/api/groups.js.map +1 -1
  25. package/dist/api/permissions.d.ts +6 -6
  26. package/dist/api/permissions.d.ts.map +1 -1
  27. package/dist/api/permissions.js +24 -9
  28. package/dist/api/permissions.js.map +1 -1
  29. package/dist/api/request-body-hash.d.ts +33 -0
  30. package/dist/api/request-body-hash.d.ts.map +1 -0
  31. package/dist/api/request-body-hash.js +43 -0
  32. package/dist/api/request-body-hash.js.map +1 -0
  33. package/dist/api/resolve-references.d.ts.map +1 -1
  34. package/dist/api/resolve-references.js +8 -1
  35. package/dist/api/resolve-references.js.map +1 -1
  36. package/dist/api/schema.js +9 -9
  37. package/dist/api/schema.js.map +1 -1
  38. package/dist/api/settings-helpers.d.ts +14 -1
  39. package/dist/api/settings-helpers.d.ts.map +1 -1
  40. package/dist/api/settings-helpers.js +11 -2
  41. package/dist/api/settings-helpers.js.map +1 -1
  42. package/dist/api/types.d.ts +10 -0
  43. package/dist/api/types.d.ts.map +1 -1
  44. package/dist/auth/index.d.ts +1 -0
  45. package/dist/auth/index.d.ts.map +1 -1
  46. package/dist/auth/index.js +1 -0
  47. package/dist/auth/index.js.map +1 -1
  48. package/dist/auth/plugin.d.ts +26 -0
  49. package/dist/auth/plugin.d.ts.map +1 -1
  50. package/dist/auth/plugin.js +25 -1
  51. package/dist/auth/plugin.js.map +1 -1
  52. package/dist/authorization/helpers.d.ts +16 -0
  53. package/dist/authorization/helpers.d.ts.map +1 -1
  54. package/dist/authorization/helpers.js +18 -0
  55. package/dist/authorization/helpers.js.map +1 -1
  56. package/dist/authorization/index.d.ts +1 -1
  57. package/dist/authorization/index.d.ts.map +1 -1
  58. package/dist/authorization/index.js +1 -1
  59. package/dist/authorization/index.js.map +1 -1
  60. package/dist/cli/cli.d.ts.map +1 -1
  61. package/dist/cli/cli.js +2 -1
  62. package/dist/cli/cli.js.map +1 -1
  63. package/dist/cli/generate-ai-content.js +744 -349
  64. package/dist/cli/init.d.ts +1 -1
  65. package/dist/cli/init.d.ts.map +1 -1
  66. package/dist/cli/init.js +28 -9
  67. package/dist/cli/init.js.map +1 -1
  68. package/dist/cli/sync.d.ts.map +1 -1
  69. package/dist/cli/sync.js +88 -60
  70. package/dist/cli/sync.js.map +1 -1
  71. package/dist/cli/template-files/canopy.ts.template +2 -1
  72. package/dist/cli/template-files/edit-page.tsx.template +8 -1
  73. package/dist/cli/template-files/middleware.ts.template +18 -0
  74. package/dist/cli/templates.d.ts.map +1 -1
  75. package/dist/cli/templates.js +16 -4
  76. package/dist/cli/templates.js.map +1 -1
  77. package/dist/config/helpers.d.ts +9 -1
  78. package/dist/config/helpers.d.ts.map +1 -1
  79. package/dist/config/helpers.js +30 -75
  80. package/dist/config/helpers.js.map +1 -1
  81. package/dist/config/schemas/config.d.ts +5 -20
  82. package/dist/config/schemas/config.d.ts.map +1 -1
  83. package/dist/config/schemas/config.js +6 -2
  84. package/dist/config/schemas/config.js.map +1 -1
  85. package/dist/config/schemas/media.d.ts +1 -10
  86. package/dist/config/schemas/media.d.ts.map +1 -1
  87. package/dist/config/schemas/media.js +8 -6
  88. package/dist/config/schemas/media.js.map +1 -1
  89. package/dist/config/types.d.ts +0 -3
  90. package/dist/config/types.d.ts.map +1 -1
  91. package/dist/content-id-index.d.ts +12 -6
  92. package/dist/content-id-index.d.ts.map +1 -1
  93. package/dist/content-id-index.js +12 -6
  94. package/dist/content-id-index.js.map +1 -1
  95. package/dist/content-index-generation.d.ts +30 -0
  96. package/dist/content-index-generation.d.ts.map +1 -0
  97. package/dist/content-index-generation.js +131 -0
  98. package/dist/content-index-generation.js.map +1 -0
  99. package/dist/content-index-registry.d.ts +38 -0
  100. package/dist/content-index-registry.d.ts.map +1 -0
  101. package/dist/content-index-registry.js +53 -0
  102. package/dist/content-index-registry.js.map +1 -0
  103. package/dist/content-store.d.ts +130 -6
  104. package/dist/content-store.d.ts.map +1 -1
  105. package/dist/content-store.js +333 -31
  106. package/dist/content-store.js.map +1 -1
  107. package/dist/editor/Editor.d.ts.map +1 -1
  108. package/dist/editor/Editor.js +8 -4
  109. package/dist/editor/Editor.js.map +1 -1
  110. package/dist/editor/FormRenderer.d.ts +7 -0
  111. package/dist/editor/FormRenderer.d.ts.map +1 -1
  112. package/dist/editor/FormRenderer.js +12 -3
  113. package/dist/editor/FormRenderer.js.map +1 -1
  114. package/dist/editor/components/EditorHeader.d.ts.map +1 -1
  115. package/dist/editor/components/EditorHeader.js +3 -4
  116. package/dist/editor/components/EditorHeader.js.map +1 -1
  117. package/dist/editor/components/EditorSidebar.d.ts.map +1 -1
  118. package/dist/editor/components/EditorSidebar.js +2 -4
  119. package/dist/editor/components/EditorSidebar.js.map +1 -1
  120. package/dist/editor/fields/ReferenceField.d.ts.map +1 -1
  121. package/dist/editor/fields/ReferenceField.js +19 -2
  122. package/dist/editor/fields/ReferenceField.js.map +1 -1
  123. package/dist/editor/group-manager/hooks/useGroupState.d.ts.map +1 -1
  124. package/dist/editor/group-manager/hooks/useGroupState.js +5 -2
  125. package/dist/editor/group-manager/hooks/useGroupState.js.map +1 -1
  126. package/dist/editor/hooks/useDraftManager.d.ts +8 -0
  127. package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
  128. package/dist/editor/hooks/useDraftManager.js +102 -1
  129. package/dist/editor/hooks/useDraftManager.js.map +1 -1
  130. package/dist/editor/hooks/useEntryManager.d.ts +9 -2
  131. package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
  132. package/dist/editor/hooks/useEntryManager.js +9 -3
  133. package/dist/editor/hooks/useEntryManager.js.map +1 -1
  134. package/dist/editor/hooks/useSchemaManager.d.ts.map +1 -1
  135. package/dist/editor/hooks/useSchemaManager.js +2 -1
  136. package/dist/editor/hooks/useSchemaManager.js.map +1 -1
  137. package/dist/editor/schema-editor/CollectionEditor.d.ts +4 -4
  138. package/dist/editor/schema-editor/CollectionEditor.d.ts.map +1 -1
  139. package/dist/editor/schema-editor/CollectionEditor.js +32 -12
  140. package/dist/editor/schema-editor/CollectionEditor.js.map +1 -1
  141. package/dist/entry-link-resolver.d.ts.map +1 -1
  142. package/dist/entry-link-resolver.js +5 -0
  143. package/dist/entry-link-resolver.js.map +1 -1
  144. package/dist/git-manager.d.ts +38 -3
  145. package/dist/git-manager.d.ts.map +1 -1
  146. package/dist/git-manager.js +141 -33
  147. package/dist/git-manager.js.map +1 -1
  148. package/dist/github-service.d.ts +13 -3
  149. package/dist/github-service.d.ts.map +1 -1
  150. package/dist/github-service.js +23 -7
  151. package/dist/github-service.js.map +1 -1
  152. package/dist/http/handler.d.ts +1 -1
  153. package/dist/http/handler.d.ts.map +1 -1
  154. package/dist/http/handler.js +36 -7
  155. package/dist/http/handler.js.map +1 -1
  156. package/dist/http/router.d.ts +11 -0
  157. package/dist/http/router.d.ts.map +1 -1
  158. package/dist/http/router.js +80 -16
  159. package/dist/http/router.js.map +1 -1
  160. package/dist/paths/validation.d.ts.map +1 -1
  161. package/dist/paths/validation.js +12 -0
  162. package/dist/paths/validation.js.map +1 -1
  163. package/dist/schema/schema-store.d.ts +9 -0
  164. package/dist/schema/schema-store.d.ts.map +1 -1
  165. package/dist/schema/schema-store.js +57 -8
  166. package/dist/schema/schema-store.js.map +1 -1
  167. package/dist/services.d.ts +1 -0
  168. package/dist/services.d.ts.map +1 -1
  169. package/dist/services.js +5 -2
  170. package/dist/services.js.map +1 -1
  171. package/dist/sync-core.d.ts.map +1 -1
  172. package/dist/sync-core.js +22 -9
  173. package/dist/sync-core.js.map +1 -1
  174. package/dist/user.d.ts +0 -18
  175. package/dist/user.d.ts.map +1 -1
  176. package/dist/user.js +7 -22
  177. package/dist/user.js.map +1 -1
  178. package/dist/utils/error.d.ts +8 -0
  179. package/dist/utils/error.d.ts.map +1 -1
  180. package/dist/utils/error.js +35 -7
  181. package/dist/utils/error.js.map +1 -1
  182. package/dist/validation/deletion-checker.d.ts.map +1 -1
  183. package/dist/validation/deletion-checker.js +6 -5
  184. package/dist/validation/deletion-checker.js.map +1 -1
  185. package/dist/validation/entry-validator.d.ts +67 -0
  186. package/dist/validation/entry-validator.d.ts.map +1 -0
  187. package/dist/validation/entry-validator.js +277 -0
  188. package/dist/validation/entry-validator.js.map +1 -0
  189. package/dist/validation/field-traversal.d.ts +23 -1
  190. package/dist/validation/field-traversal.d.ts.map +1 -1
  191. package/dist/validation/field-traversal.js +29 -9
  192. package/dist/validation/field-traversal.js.map +1 -1
  193. package/dist/worker/cms-worker.d.ts +67 -6
  194. package/dist/worker/cms-worker.d.ts.map +1 -1
  195. package/dist/worker/cms-worker.js +190 -67
  196. package/dist/worker/cms-worker.js.map +1 -1
  197. package/package.json +1 -2
  198. package/dist/editor/components/EditorContext.d.ts +0 -35
  199. package/dist/editor/components/EditorContext.d.ts.map +0 -1
  200. package/dist/editor/components/EditorContext.js +0 -19
  201. package/dist/editor/components/EditorContext.js.map +0 -1
  202. package/dist/editor/env.d.ts +0 -5
  203. package/dist/editor/env.d.ts.map +0 -1
  204. package/dist/editor/env.js +0 -5
  205. package/dist/editor/env.js.map +0 -1
@@ -9,11 +9,11 @@ var __export = (target, all) => {
9
9
  };
10
10
 
11
11
  // dist/paths/normalize.js
12
- function normalizeFilesystemPath(path15) {
13
- return path15.split(/[\\/]+/).filter(Boolean).join("/");
12
+ function normalizeFilesystemPath(path17) {
13
+ return path17.split(/[\\/]+/).filter(Boolean).join("/");
14
14
  }
15
- function hasTraversalSequence(path15) {
16
- const normalized = normalizeFilesystemPath(path15);
15
+ function hasTraversalSequence(path17) {
16
+ const normalized = normalizeFilesystemPath(path17);
17
17
  return normalized.includes("..");
18
18
  }
19
19
  function createLogicalPath(...segments) {
@@ -23,14 +23,14 @@ function createLogicalPath(...segments) {
23
23
  }
24
24
  return normalized;
25
25
  }
26
- function trimSlashes(path15) {
26
+ function trimSlashes(path17) {
27
27
  let start = 0;
28
- let end = path15.length;
29
- while (start < end && path15[start] === "/")
28
+ let end = path17.length;
29
+ while (start < end && path17[start] === "/")
30
30
  start++;
31
- while (end > start && path15[end - 1] === "/")
31
+ while (end > start && path17[end - 1] === "/")
32
32
  end--;
33
- return path15.slice(start, end);
33
+ return path17.slice(start, end);
34
34
  }
35
35
  var init_normalize = __esm({
36
36
  "dist/paths/normalize.js"() {
@@ -284,7 +284,7 @@ var mediaSchema;
284
284
  var init_media = __esm({
285
285
  "dist/config/schemas/media.js"() {
286
286
  "use strict";
287
- mediaSchema = z3.union([
287
+ mediaSchema = z3.discriminatedUnion("adapter", [
288
288
  z3.object({
289
289
  adapter: z3.literal("local"),
290
290
  publicBaseUrl: z3.string().url().optional()
@@ -298,10 +298,6 @@ var init_media = __esm({
298
298
  z3.object({
299
299
  adapter: z3.literal("lfs"),
300
300
  publicBaseUrl: z3.string().url().optional()
301
- }),
302
- z3.object({
303
- adapter: z3.string().min(1),
304
- publicBaseUrl: z3.string().url().optional()
305
301
  })
306
302
  ]);
307
303
  }
@@ -345,8 +341,12 @@ var init_config = __esm({
345
341
  });
346
342
  CanopyConfigSchema = z4.object({
347
343
  media: mediaSchema.optional(),
348
- defaultBranchAccess: defaultBranchAccessSchema.optional(),
349
- defaultPathAccess: defaultPathAccessSchema.optional(),
344
+ // No outer .optional() here: defaultBranchAccessSchema/defaultPathAccessSchema already
345
+ // have .default('deny'), so the field is optional on input but always resolves to
346
+ // 'allow'/'deny' (never undefined) on output. An outer .optional() would short-circuit
347
+ // before the inner default runs, defeating the fail-closed default (SCH-M1).
348
+ defaultBranchAccess: defaultBranchAccessSchema,
349
+ defaultPathAccess: defaultPathAccessSchema,
350
350
  // .optional() deliberately defeats defaultBaseBranchSchema's .default('main'):
351
351
  // unset must stay undefined after parsing so dev mode can detect the fork
352
352
  // point from git HEAD (see resolveBaseBranch in utils/git.ts).
@@ -541,7 +541,7 @@ var init_config2 = __esm({
541
541
  });
542
542
 
543
543
  // dist/operating-mode/client-unsafe-strategy.js
544
- import path3 from "node:path";
544
+ import path5 from "node:path";
545
545
  function operatingStrategy(mode) {
546
546
  const cached = strategyCache.get(mode);
547
547
  if (cached)
@@ -578,32 +578,32 @@ var init_client_unsafe_strategy = __esm({
578
578
  // - shouldCommit(), shouldPush()
579
579
  // Add client-unsafe methods (use Node.js APIs)
580
580
  getWorkspaceRoot(_sourceRoot) {
581
- return path3.resolve(process.env.CANOPYCMS_WORKSPACE_ROOT ?? DEFAULT_PROD_WORKSPACE);
581
+ return path5.resolve(process.env.CANOPYCMS_WORKSPACE_ROOT ?? DEFAULT_PROD_WORKSPACE);
582
582
  }
583
583
  getContentRoot(sourceRoot) {
584
- return path3.resolve(sourceRoot ?? process.cwd(), "content");
584
+ return path5.resolve(sourceRoot ?? process.cwd(), "content");
585
585
  }
586
586
  getContentBranchesRoot(sourceRoot) {
587
- return path3.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
587
+ return path5.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
588
588
  }
589
589
  getContentBranchRoot(branchName, sourceRoot) {
590
- return path3.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
590
+ return path5.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
591
591
  }
592
592
  getGitExcludePattern() {
593
593
  return ".canopy-meta/";
594
594
  }
595
595
  getPermissionsFilePath(root) {
596
- return path3.join(root, this.getPermissionsFileName());
596
+ return path5.join(root, this.getPermissionsFileName());
597
597
  }
598
598
  getGroupsFilePath(root) {
599
- return path3.join(root, this.getGroupsFileName());
599
+ return path5.join(root, this.getGroupsFileName());
600
600
  }
601
601
  getRemoteUrlConfig() {
602
602
  return {
603
603
  shouldAutoInitLocal: false,
604
604
  defaultRemotePath: "",
605
605
  envVarName: "CANOPYCMS_REMOTE_URL",
606
- autoDetectRemotePath: path3.join(this.getWorkspaceRoot(), "remote.git")
606
+ autoDetectRemotePath: path5.join(this.getWorkspaceRoot(), "remote.git")
607
607
  };
608
608
  }
609
609
  requiresExistingRepo() {
@@ -616,7 +616,7 @@ var init_client_unsafe_strategy = __esm({
616
616
  return `canopycms-settings-${deploymentName}`;
617
617
  }
618
618
  getSettingsRoot(sourceRoot) {
619
- return path3.join(this.getWorkspaceRoot(sourceRoot), "settings");
619
+ return path5.join(this.getWorkspaceRoot(sourceRoot), "settings");
620
620
  }
621
621
  usesSeparateSettingsBranch() {
622
622
  return true;
@@ -633,25 +633,25 @@ var init_client_unsafe_strategy = __esm({
633
633
  DevStrategy = class extends DevClientSafeStrategy {
634
634
  // Inherits client-safe methods from DevClientSafeStrategy
635
635
  getWorkspaceRoot(sourceRoot) {
636
- return path3.resolve(sourceRoot ?? process.cwd(), ".canopy-dev");
636
+ return path5.resolve(sourceRoot ?? process.cwd(), ".canopy-dev");
637
637
  }
638
638
  getContentRoot(sourceRoot) {
639
- return path3.resolve(sourceRoot ?? process.cwd(), "content");
639
+ return path5.resolve(sourceRoot ?? process.cwd(), "content");
640
640
  }
641
641
  getContentBranchesRoot(sourceRoot) {
642
- return path3.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
642
+ return path5.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
643
643
  }
644
644
  getContentBranchRoot(branchName, sourceRoot) {
645
- return path3.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
645
+ return path5.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
646
646
  }
647
647
  getGitExcludePattern() {
648
648
  return ".canopy-meta/";
649
649
  }
650
650
  getPermissionsFilePath(root) {
651
- return path3.join(root, this.getPermissionsFileName());
651
+ return path5.join(root, this.getPermissionsFileName());
652
652
  }
653
653
  getGroupsFilePath(root) {
654
- return path3.join(root, this.getGroupsFileName());
654
+ return path5.join(root, this.getGroupsFileName());
655
655
  }
656
656
  getRemoteUrlConfig() {
657
657
  return {
@@ -670,7 +670,7 @@ var init_client_unsafe_strategy = __esm({
670
670
  return `canopycms-settings-${deploymentName}`;
671
671
  }
672
672
  getSettingsRoot(sourceRoot) {
673
- return path3.join(this.getWorkspaceRoot(sourceRoot), "settings");
673
+ return path5.join(this.getWorkspaceRoot(sourceRoot), "settings");
674
674
  }
675
675
  usesSeparateSettingsBranch() {
676
676
  return true;
@@ -702,16 +702,16 @@ var init_operating_mode = __esm({
702
702
  });
703
703
 
704
704
  // dist/cli/generate-ai-content.js
705
- import path14 from "node:path";
705
+ import path16 from "node:path";
706
706
  import { createJiti } from "jiti";
707
707
 
708
708
  // dist/build/generate-ai-content.js
709
- import fs12 from "node:fs/promises";
710
- import path13 from "node:path";
709
+ import fs14 from "node:fs/promises";
710
+ import path15 from "node:path";
711
711
 
712
712
  // dist/content-store.js
713
- import fs4 from "node:fs/promises";
714
- import path5 from "node:path";
713
+ import fs6 from "node:fs/promises";
714
+ import path7 from "node:path";
715
715
  import matter from "gray-matter";
716
716
  import { parse as yamlParse, stringify as yamlStringify } from "yaml";
717
717
 
@@ -1076,13 +1076,13 @@ function extractIdFromFilename(filename) {
1076
1076
  return null;
1077
1077
  }
1078
1078
  async function resolveCollectionPath(root, logicalPath) {
1079
- const fs13 = await import("node:fs/promises");
1080
- const path15 = await import("node:path");
1079
+ const fs15 = await import("node:fs/promises");
1080
+ const path17 = await import("node:path");
1081
1081
  const segments = logicalPath.split("/").filter(Boolean);
1082
1082
  let currentPath = root;
1083
1083
  for (const segment of segments) {
1084
1084
  try {
1085
- const entries = await fs13.readdir(currentPath, { withFileTypes: true });
1085
+ const entries = await fs15.readdir(currentPath, { withFileTypes: true });
1086
1086
  const matchingDir = entries.find((entry) => {
1087
1087
  if (!entry.isDirectory())
1088
1088
  return false;
@@ -1090,7 +1090,7 @@ async function resolveCollectionPath(root, logicalPath) {
1090
1090
  return logicalName === segment.toLowerCase();
1091
1091
  });
1092
1092
  if (matchingDir) {
1093
- currentPath = path15.join(currentPath, matchingDir.name);
1093
+ currentPath = path17.join(currentPath, matchingDir.name);
1094
1094
  } else {
1095
1095
  return null;
1096
1096
  }
@@ -1140,6 +1140,192 @@ function extractSlugFromFilename(filename, entryTypeName) {
1140
1140
  return filename.toLowerCase();
1141
1141
  }
1142
1142
 
1143
+ // dist/content-index-registry.js
1144
+ import path3 from "node:path";
1145
+ var registry = /* @__PURE__ */ new Map();
1146
+ var finalization = new FinalizationRegistry(({ rootKey, ref }) => {
1147
+ const refs = registry.get(rootKey);
1148
+ if (!refs)
1149
+ return;
1150
+ refs.delete(ref);
1151
+ if (refs.size === 0)
1152
+ registry.delete(rootKey);
1153
+ });
1154
+ function registerContentIndexForInvalidation(root, target) {
1155
+ const rootKey = path3.resolve(root);
1156
+ let refs = registry.get(rootKey);
1157
+ if (!refs) {
1158
+ refs = /* @__PURE__ */ new Set();
1159
+ registry.set(rootKey, refs);
1160
+ }
1161
+ const ref = new WeakRef(target);
1162
+ refs.add(ref);
1163
+ finalization.register(target, { rootKey, ref });
1164
+ }
1165
+ function invalidateContentIndexesForRoot(root) {
1166
+ const rootKey = path3.resolve(root);
1167
+ const prefix = rootKey + path3.sep;
1168
+ for (const [storeRoot, refs] of registry) {
1169
+ if (storeRoot !== rootKey && !storeRoot.startsWith(prefix))
1170
+ continue;
1171
+ for (const ref of refs) {
1172
+ const target = ref.deref();
1173
+ if (target) {
1174
+ target.invalidateIndex();
1175
+ } else {
1176
+ refs.delete(ref);
1177
+ }
1178
+ }
1179
+ if (refs.size === 0)
1180
+ registry.delete(storeRoot);
1181
+ }
1182
+ }
1183
+
1184
+ // dist/content-index-generation.js
1185
+ import fs3 from "node:fs/promises";
1186
+ import path4 from "node:path";
1187
+ import { randomUUID } from "node:crypto";
1188
+
1189
+ // dist/utils/debug.js
1190
+ var LOG_LEVELS = {
1191
+ DEBUG: 0,
1192
+ INFO: 1,
1193
+ WARN: 2,
1194
+ ERROR: 3
1195
+ };
1196
+ var DebugLogger = class {
1197
+ constructor(options = {}) {
1198
+ this.timers = /* @__PURE__ */ new Map();
1199
+ this.options = options;
1200
+ }
1201
+ shouldLog(level) {
1202
+ const enabled = this.options.enabled ?? process.env.CANOPYCMS_DEBUG === "true";
1203
+ if (!enabled)
1204
+ return false;
1205
+ const minLevel = this.options.minLevel ?? "DEBUG";
1206
+ return LOG_LEVELS[level] >= LOG_LEVELS[minLevel];
1207
+ }
1208
+ formatMessage(level, category, message) {
1209
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1210
+ const prefix = this.options.prefix ?? "CanopyCMS";
1211
+ return `[${timestamp}] [${prefix}:${category}] [${level}] ${message}`;
1212
+ }
1213
+ debug(category, message, data) {
1214
+ if (this.shouldLog("DEBUG")) {
1215
+ console.log(this.formatMessage("DEBUG", category, message), data ?? "");
1216
+ }
1217
+ }
1218
+ info(category, message, data) {
1219
+ if (this.shouldLog("INFO")) {
1220
+ console.log(this.formatMessage("INFO", category, message), data ?? "");
1221
+ }
1222
+ }
1223
+ warn(category, message, data) {
1224
+ if (this.shouldLog("WARN")) {
1225
+ console.warn(this.formatMessage("WARN", category, message), data ?? "");
1226
+ }
1227
+ }
1228
+ error(category, message, data) {
1229
+ const msg = this.formatMessage("ERROR", category, message);
1230
+ if (this.shouldLog("ERROR")) {
1231
+ console.error(msg, data ?? "");
1232
+ }
1233
+ const throwOnError = this.options.throwOnError ?? false;
1234
+ if (throwOnError) {
1235
+ const errorMsg = data ? `${message}: ${JSON.stringify(data)}` : message;
1236
+ throw new Error(errorMsg);
1237
+ }
1238
+ }
1239
+ /**
1240
+ * Start timing an operation
1241
+ */
1242
+ time(label) {
1243
+ this.timers.set(label, Date.now());
1244
+ }
1245
+ /**
1246
+ * End timing an operation and log the duration
1247
+ */
1248
+ timeEnd(category, label) {
1249
+ const start = this.timers.get(label);
1250
+ if (start === void 0) {
1251
+ this.warn(category, `Timer '${label}' does not exist`);
1252
+ return;
1253
+ }
1254
+ const duration = Date.now() - start;
1255
+ this.timers.delete(label);
1256
+ this.debug(category, `${label} completed`, { durationMs: duration });
1257
+ return duration;
1258
+ }
1259
+ /**
1260
+ * Wrap an async function with automatic timing
1261
+ */
1262
+ async timed(category, label, fn) {
1263
+ this.time(label);
1264
+ try {
1265
+ return await fn();
1266
+ } finally {
1267
+ this.timeEnd(category, label);
1268
+ }
1269
+ }
1270
+ };
1271
+ function createDebugLogger(options) {
1272
+ return new DebugLogger(options);
1273
+ }
1274
+ var testLogger = createDebugLogger({
1275
+ enabled: process.env.E2E_DEBUG === "true",
1276
+ prefix: "E2E",
1277
+ throwOnError: false
1278
+ });
1279
+
1280
+ // dist/content-index-generation.js
1281
+ var log = createDebugLogger({ prefix: "ContentIndex" });
1282
+ var META_DIR = ".canopy-meta";
1283
+ var GENERATION_FILE = "content-index.generation";
1284
+ function contentIndexGenerationPath(root) {
1285
+ return path4.join(path4.resolve(root), META_DIR, GENERATION_FILE);
1286
+ }
1287
+ async function bumpContentIndexGeneration(root) {
1288
+ const token = randomUUID();
1289
+ try {
1290
+ await atomicWriteFile(contentIndexGenerationPath(root), token);
1291
+ return token;
1292
+ } catch (err) {
1293
+ log.warn("generation", `Failed to bump content index generation for ${root}`, {
1294
+ error: getErrorMessage(err)
1295
+ });
1296
+ return null;
1297
+ }
1298
+ }
1299
+ async function readContentIndexGeneration(root) {
1300
+ try {
1301
+ return await fs3.readFile(contentIndexGenerationPath(root), "utf-8");
1302
+ } catch (err) {
1303
+ if (isNodeError(err) && err.code === "ENOENT")
1304
+ return null;
1305
+ log.warn("generation", `Failed to read content index generation for ${root}`, {
1306
+ error: getErrorMessage(err)
1307
+ });
1308
+ return null;
1309
+ }
1310
+ }
1311
+ async function invalidateContentIndexesDurable(root) {
1312
+ await bumpContentIndexGeneration(root);
1313
+ invalidateContentIndexesForRoot(root);
1314
+ }
1315
+
1316
+ // dist/utils/fs.js
1317
+ import fs4 from "node:fs/promises";
1318
+ async function filePathExists(filePath) {
1319
+ try {
1320
+ await fs4.stat(filePath);
1321
+ return true;
1322
+ } catch (err) {
1323
+ if (isNotFoundError(err))
1324
+ return false;
1325
+ throw err;
1326
+ }
1327
+ }
1328
+
1143
1329
  // dist/utils/format.js
1144
1330
  var getFormatExtension = (format) => {
1145
1331
  if (format === "md")
@@ -1158,8 +1344,8 @@ init_normalize();
1158
1344
 
1159
1345
  // dist/paths/branch.js
1160
1346
  init_operating_mode();
1161
- import fs3 from "node:fs/promises";
1162
- import path4 from "node:path";
1347
+ import fs5 from "node:fs/promises";
1348
+ import path6 from "node:path";
1163
1349
  var BranchPathError = class extends Error {
1164
1350
  };
1165
1351
  function sanitizeBranchName(branchName) {
@@ -1178,11 +1364,11 @@ function resolveBranchPath(options) {
1178
1364
  const safeBranch = sanitizeBranchName(options.branchName);
1179
1365
  const strategy = operatingStrategy(options.mode);
1180
1366
  const baseRoot = resolveContentBranchesRoot(options.mode, options.basePathOverride);
1181
- const normalizedBase = path4.resolve(baseRoot);
1182
- const baseWithSep = normalizedBase.endsWith(path4.sep) ? normalizedBase : `${normalizedBase}${path4.sep}`;
1367
+ const normalizedBase = path6.resolve(baseRoot);
1368
+ const baseWithSep = normalizedBase.endsWith(path6.sep) ? normalizedBase : `${normalizedBase}${path6.sep}`;
1183
1369
  const branchRoot = strategy.getContentBranchRoot(safeBranch, options.basePathOverride);
1184
1370
  const withinBase = (target) => {
1185
- const resolved = path4.resolve(target);
1371
+ const resolved = path6.resolve(target);
1186
1372
  return resolved === normalizedBase || resolved.startsWith(baseWithSep);
1187
1373
  };
1188
1374
  if (!withinBase(branchRoot)) {
@@ -1192,7 +1378,7 @@ function resolveBranchPath(options) {
1192
1378
  }
1193
1379
  async function ensureBranchRoot(options) {
1194
1380
  const result = resolveBranchPath(options);
1195
- await fs3.mkdir(result.branchRoot, { recursive: true });
1381
+ await fs5.mkdir(result.branchRoot, { recursive: true });
1196
1382
  return result;
1197
1383
  }
1198
1384
 
@@ -1222,24 +1408,147 @@ function validateSlug(slug) {
1222
1408
  throw new ContentStoreError("Slugs cannot contain backslashes. Use nested collections instead.", "VALIDATION");
1223
1409
  }
1224
1410
  }
1411
+ var DEFAULT_INDEX_FRESHNESS_INTERVAL_MS = 1e3;
1412
+ var FORCED_REFRESH_MIN_INTERVAL_MS = 5e3;
1413
+ var STALE_LOOKUP = Symbol("stale-index-lookup");
1225
1414
  var ContentStore = class {
1226
- constructor(root, flatSchema) {
1227
- this.indexLoaded = false;
1228
- this.root = path5.resolve(root);
1415
+ constructor(root, flatSchema, options = {}) {
1416
+ this.indexGeneration = 0;
1417
+ this.loadedIndexGeneration = -1;
1418
+ this.loadedDiskGeneration = void 0;
1419
+ this.lastDiskProbeMs = 0;
1420
+ this.lastForcedRefreshMs = 0;
1421
+ this.indexBuild = null;
1422
+ this.root = path7.resolve(root);
1423
+ this.indexFreshnessIntervalMs = options.indexFreshnessIntervalMs ?? DEFAULT_INDEX_FRESHNESS_INTERVAL_MS;
1229
1424
  this.schemaIndex = new Map(flatSchema.map((item) => [item.logicalPath, item]));
1230
1425
  this._idIndex = new ContentIdIndex(this.root);
1426
+ registerContentIndexForInvalidation(this.root, this);
1427
+ }
1428
+ /**
1429
+ * Mark the ID index stale so the next idIndex() access rebuilds it from disk.
1430
+ *
1431
+ * Called (via content-index-registry) after in-process operations that change the
1432
+ * working tree underneath this store — git checkout/merge/rebase, the worker's
1433
+ * rebase loop, and sync-core's content replacement. Without this, ID→path lookups
1434
+ * keep resolving to pre-mutation paths and saves can target moved/deleted files.
1435
+ *
1436
+ * Cheap: only bumps a generation counter; the rebuild happens lazily.
1437
+ */
1438
+ invalidateIndex() {
1439
+ this.indexGeneration++;
1231
1440
  }
1232
1441
  /**
1233
- * Get the ID index, ensuring it's loaded first.
1234
- * This getter automatically loads the index on first access.
1442
+ * Get the ID index, ensuring it's loaded and current first.
1443
+ * Loads lazily on first access and rebuilds after invalidateIndex(); repeated
1444
+ * accesses with no intervening invalidation reuse the already-built index,
1445
+ * except for a throttled probe of the on-disk generation marker that detects
1446
+ * mutations made by OTHER processes sharing this root (e.g. on EFS).
1235
1447
  */
1236
1448
  async idIndex() {
1237
- if (!this.indexLoaded) {
1238
- await this._idIndex.buildFromFilenames("content");
1239
- this.indexLoaded = true;
1449
+ if (this.loadedIndexGeneration === this.indexGeneration && this.shouldProbeDiskGeneration()) {
1450
+ const diskToken = await readContentIndexGeneration(this.root);
1451
+ if (diskToken !== this.loadedDiskGeneration) {
1452
+ this.indexGeneration++;
1453
+ }
1454
+ }
1455
+ while (this.loadedIndexGeneration !== this.indexGeneration) {
1456
+ if (this.indexBuild) {
1457
+ await this.indexBuild;
1458
+ continue;
1459
+ }
1460
+ const generation = this.indexGeneration;
1461
+ const build = (async () => {
1462
+ const diskToken = await readContentIndexGeneration(this.root);
1463
+ const fresh = new ContentIdIndex(this.root);
1464
+ await fresh.buildFromFilenames("content");
1465
+ return { diskToken, fresh };
1466
+ })();
1467
+ this.indexBuild = build;
1468
+ let result;
1469
+ try {
1470
+ result = await build;
1471
+ } finally {
1472
+ this.indexBuild = null;
1473
+ }
1474
+ this._idIndex = result.fresh;
1475
+ this.loadedIndexGeneration = generation;
1476
+ this.loadedDiskGeneration = result.diskToken;
1477
+ this.lastDiskProbeMs = Date.now();
1240
1478
  }
1241
1479
  return this._idIndex;
1242
1480
  }
1481
+ /**
1482
+ * Throttle for the on-disk marker probe. Stamps the clock synchronously
1483
+ * before the caller's readFile so concurrent idIndex() callers don't
1484
+ * double-probe (and double-rebuild) on the same token change.
1485
+ */
1486
+ shouldProbeDiskGeneration() {
1487
+ const now = Date.now();
1488
+ if (now - this.lastDiskProbeMs < this.indexFreshnessIntervalMs)
1489
+ return false;
1490
+ this.lastDiskProbeMs = now;
1491
+ return true;
1492
+ }
1493
+ /**
1494
+ * After one of this store's own successful mutations: publish the change to
1495
+ * other processes (bump the on-disk marker) and adopt the written token,
1496
+ * since the in-memory index was already updated incrementally — avoiding a
1497
+ * pointless self-rescan on the next probe. Adoption is skipped unless the
1498
+ * index is quiescent and `updatedIndex` is still the live instance: if a
1499
+ * rebuild raced with the mutation, its scan may predate our file change, so
1500
+ * we leave the recorded token older and let the next probe observe our bump
1501
+ * and trigger the healing rebuild.
1502
+ */
1503
+ async recordOwnMutation(updatedIndex) {
1504
+ const token = await bumpContentIndexGeneration(this.root);
1505
+ if (token !== null && this.indexBuild === null && this.loadedIndexGeneration === this.indexGeneration && this._idIndex === updatedIndex) {
1506
+ this.loadedDiskGeneration = token;
1507
+ }
1508
+ }
1509
+ /**
1510
+ * Backstop for the residual staleness windows of the cross-process marker
1511
+ * (NFS attribute caching, probe throttle, self-adoption — see
1512
+ * content-index-generation.ts): force one rebuild in response to a
1513
+ * suspicious lookup (an ID miss, or an index hit whose file is gone), but
1514
+ * throttle how often a caller can force one. Time-boxed so genuinely
1515
+ * dangling IDs cost at most one rescan per window. Returns true if a
1516
+ * refresh was performed by THIS call; callers that lose the throttle race
1517
+ * still benefit — idIndex() dedupes concurrent builds, so a caller that won
1518
+ * the race rebuilds the index for everyone, and callers should still retry
1519
+ * their lookup against the live index regardless of this return value.
1520
+ */
1521
+ async refreshIndexForSuspiciousLookup() {
1522
+ const now = Date.now();
1523
+ if (now - this.lastForcedRefreshMs < FORCED_REFRESH_MIN_INTERVAL_MS)
1524
+ return false;
1525
+ this.lastForcedRefreshMs = now;
1526
+ this.invalidateIndex();
1527
+ await this.idIndex();
1528
+ return true;
1529
+ }
1530
+ /**
1531
+ * Find the file in `dir` whose filename embeds `id`.
1532
+ * Returns its root-relative path, or null if no such file (or no such dir).
1533
+ */
1534
+ async findEntryPathById(dir, id) {
1535
+ let entries;
1536
+ try {
1537
+ entries = await fs6.readdir(dir, { withFileTypes: true });
1538
+ } catch (err) {
1539
+ if (isNodeError(err) && err.code === "ENOENT")
1540
+ return null;
1541
+ throw err;
1542
+ }
1543
+ for (const entry of entries) {
1544
+ if (entry.isDirectory())
1545
+ continue;
1546
+ if (extractIdFromFilename(entry.name) === id) {
1547
+ return path7.relative(this.root, path7.join(dir, entry.name));
1548
+ }
1549
+ }
1550
+ return null;
1551
+ }
1243
1552
  /**
1244
1553
  * Get all schema items for iteration.
1245
1554
  * Used internally by ReferenceResolver for path matching.
@@ -1247,11 +1556,11 @@ var ContentStore = class {
1247
1556
  getSchemaItems() {
1248
1557
  return this.schemaIndex.values();
1249
1558
  }
1250
- assertSchemaItem(path15) {
1251
- const normalized = normalizeFilesystemPath(path15);
1559
+ assertSchemaItem(path17) {
1560
+ const normalized = normalizeFilesystemPath(path17);
1252
1561
  const item = this.schemaIndex.get(normalized);
1253
1562
  if (!item) {
1254
- throw new ContentStoreError(`Unknown schema item: ${path15}`, "NO_SCHEMA_ITEM");
1563
+ throw new ContentStoreError(`Unknown schema item: ${path17}`, "NO_SCHEMA_ITEM");
1255
1564
  }
1256
1565
  return item;
1257
1566
  }
@@ -1278,7 +1587,7 @@ var ContentStore = class {
1278
1587
  * @param options.entryTypeName - For collections with multiple entry types, specify which one to use. Defaults to the default entry type.
1279
1588
  */
1280
1589
  async buildPaths(schemaItem, slug, options = {}) {
1281
- const rootWithSep = this.root.endsWith(path5.sep) ? this.root : `${this.root}${path5.sep}`;
1590
+ const rootWithSep = this.root.endsWith(path7.sep) ? this.root : `${this.root}${path7.sep}`;
1282
1591
  if (schemaItem.type === "entry-type") {
1283
1592
  const parentPath = schemaItem.parentPath || "";
1284
1593
  const parentCollection = this.schemaIndex.get(parentPath);
@@ -1311,7 +1620,7 @@ var ContentStore = class {
1311
1620
  const entryTypeName = entryTypeConfig?.name || "entry";
1312
1621
  let collectionRoot = await resolveCollectionPath(this.root, schemaItem.logicalPath);
1313
1622
  if (!collectionRoot) {
1314
- collectionRoot = path5.resolve(this.root, schemaItem.logicalPath);
1623
+ collectionRoot = path7.resolve(this.root, schemaItem.logicalPath);
1315
1624
  }
1316
1625
  if (!collectionRoot.startsWith(rootWithSep)) {
1317
1626
  throw new ContentStoreError("Path traversal detected", "VALIDATION");
@@ -1320,7 +1629,7 @@ var ContentStore = class {
1320
1629
  let existingFilename;
1321
1630
  let existingEntryType;
1322
1631
  if (!id) {
1323
- const entries = await fs4.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
1632
+ const entries = await fs6.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
1324
1633
  const existingFile = entries.find((entry) => {
1325
1634
  if (entry.isDirectory())
1326
1635
  return false;
@@ -1344,14 +1653,14 @@ var ContentStore = class {
1344
1653
  }
1345
1654
  filename = `${finalEntryTypeName}.${safeSlug}.${id}${ext}`;
1346
1655
  }
1347
- const resolved = path5.resolve(collectionRoot, filename);
1348
- const collectionRootWithSep = collectionRoot.endsWith(path5.sep) ? collectionRoot : `${collectionRoot}${path5.sep}`;
1656
+ const resolved = path7.resolve(collectionRoot, filename);
1657
+ const collectionRootWithSep = collectionRoot.endsWith(path7.sep) ? collectionRoot : `${collectionRoot}${path7.sep}`;
1349
1658
  if (!resolved.startsWith(collectionRootWithSep)) {
1350
1659
  throw new ContentStoreError("Path traversal detected", "VALIDATION");
1351
1660
  }
1352
1661
  return {
1353
1662
  absolutePath: resolved,
1354
- relativePath: path5.relative(this.root, resolved),
1663
+ relativePath: path7.relative(this.root, resolved),
1355
1664
  id,
1356
1665
  entryTypeName: finalEntryTypeName
1357
1666
  };
@@ -1386,8 +1695,8 @@ var ContentStore = class {
1386
1695
  async read(collectionPath, slug = "", options = {}) {
1387
1696
  const schemaItem = this.assertSchemaItem(collectionPath);
1388
1697
  const { absolutePath, relativePath, entryTypeName: resolvedEntryTypeName } = await this.buildPaths(schemaItem, slug);
1389
- const stat = await fs4.stat(absolutePath);
1390
- const raw = await fs4.readFile(absolutePath, "utf8");
1698
+ const stat = await fs6.stat(absolutePath);
1699
+ const raw = await fs6.readFile(absolutePath, "utf8");
1391
1700
  let doc;
1392
1701
  let format;
1393
1702
  let fields;
@@ -1445,7 +1754,7 @@ var ContentStore = class {
1445
1754
  return doc;
1446
1755
  }
1447
1756
  async write(collectionPath, slug = "", input, entryTypeName, existingId) {
1448
- const idIndex = await this.idIndex();
1757
+ await this.idIndex();
1449
1758
  const schemaItem = this.assertSchemaItem(collectionPath);
1450
1759
  let expectedFormat;
1451
1760
  let fields = [];
@@ -1473,10 +1782,10 @@ var ContentStore = class {
1473
1782
  existingId
1474
1783
  });
1475
1784
  return withLock(absolutePath, async () => {
1476
- await fs4.mkdir(path5.dirname(absolutePath), { recursive: true });
1785
+ await fs6.mkdir(path7.dirname(absolutePath), { recursive: true });
1477
1786
  if (input.expectedVersion !== void 0) {
1478
1787
  try {
1479
- const existing = await fs4.stat(absolutePath);
1788
+ const existing = await fs6.stat(absolutePath);
1480
1789
  if (existing.mtimeMs !== input.expectedVersion) {
1481
1790
  throw new ContentConflictError();
1482
1791
  }
@@ -1489,6 +1798,13 @@ var ContentStore = class {
1489
1798
  }
1490
1799
  }
1491
1800
  }
1801
+ if (existingId && !await filePathExists(absolutePath)) {
1802
+ const actualRelPath = await this.findEntryPathById(path7.dirname(absolutePath), existingId);
1803
+ const indexedRelPath = (await this.idIndex()).findById(existingId)?.relativePath ?? null;
1804
+ if (actualRelPath !== null && actualRelPath !== indexedRelPath) {
1805
+ throw new ContentConflictError();
1806
+ }
1807
+ }
1492
1808
  let content;
1493
1809
  if (input.format === "json") {
1494
1810
  content = `${JSON.stringify(input.data ?? {}, null, 2)}
@@ -1499,19 +1815,17 @@ var ContentStore = class {
1499
1815
  content = matter.stringify(input.body, input.data ?? {});
1500
1816
  }
1501
1817
  await atomicWriteFile(absolutePath, content);
1818
+ const liveIndex = this._idIndex;
1819
+ let staleOldAbsPath = null;
1502
1820
  if (id) {
1503
- const existing = idIndex.findById(id);
1821
+ const existing = liveIndex.findById(id);
1504
1822
  if (existing) {
1505
1823
  if (existing.relativePath !== relativePath) {
1506
- const oldAbsPath = path5.join(this.root, existing.relativePath);
1507
- await fs4.unlink(oldAbsPath).catch((err) => {
1508
- if (!isNodeError(err) || err.code !== "ENOENT")
1509
- throw err;
1510
- });
1511
- idIndex.updatePath(existing.id, relativePath);
1824
+ staleOldAbsPath = path7.join(this.root, existing.relativePath);
1825
+ liveIndex.updatePath(existing.id, relativePath);
1512
1826
  }
1513
1827
  } else {
1514
- idIndex.add({
1828
+ liveIndex.add({
1515
1829
  type: "entry",
1516
1830
  relativePath,
1517
1831
  collection: collectionPath,
@@ -1519,7 +1833,14 @@ var ContentStore = class {
1519
1833
  });
1520
1834
  }
1521
1835
  }
1522
- const afterStat = await fs4.stat(absolutePath);
1836
+ if (staleOldAbsPath) {
1837
+ await fs6.unlink(staleOldAbsPath).catch((err) => {
1838
+ if (!isNodeError(err) || err.code !== "ENOENT")
1839
+ throw err;
1840
+ });
1841
+ }
1842
+ await this.recordOwnMutation(liveIndex);
1843
+ const afterStat = await fs6.stat(absolutePath);
1523
1844
  const base = {
1524
1845
  collection: schemaItem.logicalPath,
1525
1846
  collectionName: schemaItem.name,
@@ -1545,13 +1866,34 @@ var ContentStore = class {
1545
1866
  /**
1546
1867
  * Read an entry by its ID (UUID).
1547
1868
  * Returns null if the ID doesn't exist or points to a collection.
1869
+ *
1870
+ * Suspicious results (ID missing from the index, or an index hit whose file
1871
+ * is gone) trigger one forced index refresh and a retry — self-healing for
1872
+ * mutations by other processes inside the marker's residual windows.
1548
1873
  */
1549
1874
  async readById(id) {
1875
+ const first = await this.readByIdOnce(id);
1876
+ if (first !== STALE_LOOKUP)
1877
+ return first;
1878
+ if (!await this.refreshIndexForSuspiciousLookup())
1879
+ return null;
1880
+ const second = await this.readByIdOnce(id);
1881
+ return second === STALE_LOOKUP ? null : second;
1882
+ }
1883
+ async readByIdOnce(id) {
1550
1884
  const idIndex = await this.idIndex();
1551
1885
  const location = idIndex.findById(id);
1552
- if (!location || location.type !== "entry")
1886
+ if (!location)
1887
+ return STALE_LOOKUP;
1888
+ if (location.type !== "entry")
1553
1889
  return null;
1554
- return this.read(location.collection, location.slug);
1890
+ try {
1891
+ return await this.read(location.collection, location.slug);
1892
+ } catch (err) {
1893
+ if (isNodeError(err) && err.code === "ENOENT")
1894
+ return STALE_LOOKUP;
1895
+ throw err;
1896
+ }
1555
1897
  }
1556
1898
  /**
1557
1899
  * Get the ID for an entry given its collection and slug.
@@ -1562,19 +1904,72 @@ var ContentStore = class {
1562
1904
  const { relativePath } = await this.buildPaths(this.assertCollection(collectionPath), slug);
1563
1905
  return idIndex.findByPath(relativePath);
1564
1906
  }
1907
+ /**
1908
+ * Check whether a document already exists on disk for this collection+slug.
1909
+ * Used by the write boundary to distinguish creates from edits (create
1910
+ * scaffolds and maxItems enforcement).
1911
+ */
1912
+ async documentExists(collectionPath, slug = "") {
1913
+ const schemaItem = this.assertSchemaItem(collectionPath);
1914
+ const { absolutePath } = await this.buildPaths(schemaItem, slug);
1915
+ try {
1916
+ await fs6.access(absolutePath);
1917
+ return true;
1918
+ } catch (err) {
1919
+ if (isNodeError(err) && err.code === "ENOENT")
1920
+ return false;
1921
+ throw err;
1922
+ }
1923
+ }
1924
+ /**
1925
+ * Resolve the actual on-disk entry type of an existing collection entry, by
1926
+ * inspecting its filename directly. Entry filenames embed their type
1927
+ * (`{type}.{slug}.{id}.{ext}`) and it is immutable after creation — see the
1928
+ * existing-file lookup in buildPaths(), which this mirrors. Returns
1929
+ * undefined if no entry exists yet at this slug.
1930
+ *
1931
+ * Used at the API write boundary (api/content.ts) to validate an existing
1932
+ * entry's payload against ITS real schema rather than a caller-supplied (or
1933
+ * omitted/spoofed) `entryType` param — ContentStore.write() preserves the
1934
+ * on-disk type regardless of what's requested, so validation must agree
1935
+ * with what will actually be written (post-review M2).
1936
+ *
1937
+ * Cheap: one buildPaths() resolution plus a stat, no file content is read —
1938
+ * same cost class as documentExists().
1939
+ */
1940
+ async getExistingEntryType(collectionPath, slug = "") {
1941
+ const schemaItem = this.assertCollection(collectionPath);
1942
+ const { absolutePath, entryTypeName } = await this.buildPaths(schemaItem, slug);
1943
+ return await filePathExists(absolutePath) ? entryTypeName : void 0;
1944
+ }
1945
+ /**
1946
+ * Count existing entries of a given entry type in a collection, by filename
1947
+ * (entry filenames embed their type: `{type}.{slug}.{id}.{ext}`). Used to
1948
+ * enforce `EntryTypeConfig.maxItems` server-side at the create boundary.
1949
+ */
1950
+ async countEntriesOfType(collectionPath, entryTypeName) {
1951
+ this.assertCollection(collectionPath);
1952
+ const collectionRoot = await resolveCollectionPath(this.root, collectionPath);
1953
+ if (!collectionRoot)
1954
+ return 0;
1955
+ const entries = await fs6.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
1956
+ return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
1957
+ }
1565
1958
  /**
1566
1959
  * Delete an entry and remove it from the index.
1567
1960
  */
1568
1961
  async delete(collectionPath, slug) {
1569
- const idIndex = await this.idIndex();
1962
+ await this.idIndex();
1570
1963
  const collection = this.assertCollection(collectionPath);
1571
1964
  const { absolutePath, relativePath } = await this.buildPaths(collection, slug);
1572
1965
  return withLock(absolutePath, async () => {
1573
- const id = idIndex.findByPath(relativePath);
1574
- await fs4.unlink(absolutePath);
1966
+ await fs6.unlink(absolutePath);
1967
+ const liveIndex = this._idIndex;
1968
+ const id = liveIndex.findByPath(relativePath);
1575
1969
  if (id) {
1576
- idIndex.remove(id);
1970
+ liveIndex.remove(id);
1577
1971
  }
1972
+ await this.recordOwnMutation(liveIndex);
1578
1973
  });
1579
1974
  }
1580
1975
  /**
@@ -1588,7 +1983,7 @@ var ContentStore = class {
1588
1983
  * @throws ContentStoreError if entry doesn't exist, new slug conflicts, or validation fails
1589
1984
  */
1590
1985
  async renameEntry(collectionPath, currentSlug, newSlug) {
1591
- const idIndex = await this.idIndex();
1986
+ await this.idIndex();
1592
1987
  const collection = this.assertCollection(collectionPath);
1593
1988
  validateSlug(newSlug);
1594
1989
  const safeNewSlug = newSlug.replace(/^\/+/, "");
@@ -1601,11 +1996,11 @@ var ContentStore = class {
1601
1996
  const { absolutePath: currentPath, relativePath: currentRelPath } = await this.buildPaths(collection, currentSlug);
1602
1997
  return withLock(currentPath, async () => {
1603
1998
  try {
1604
- await fs4.access(currentPath);
1999
+ await fs6.access(currentPath);
1605
2000
  } catch {
1606
2001
  throw new ContentStoreError(`Entry not found: ${currentSlug}`, "NOT_FOUND");
1607
2002
  }
1608
- const currentFilename = path5.basename(currentPath);
2003
+ const currentFilename = path7.basename(currentPath);
1609
2004
  const parts = currentFilename.split(".");
1610
2005
  if (parts.length < 4) {
1611
2006
  throw new ContentStoreError(`Invalid entry filename format: ${currentFilename}`, "VALIDATION");
@@ -1614,10 +2009,10 @@ var ContentStore = class {
1614
2009
  const contentId = parts[parts.length - 2];
1615
2010
  const ext = `.${parts[parts.length - 1]}`;
1616
2011
  const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
1617
- const parentDir = path5.dirname(currentPath);
1618
- const newPath = path5.join(parentDir, newFilename);
2012
+ const parentDir = path7.dirname(currentPath);
2013
+ const newPath = path7.join(parentDir, newFilename);
1619
2014
  try {
1620
- const entries = await fs4.readdir(parentDir, { withFileTypes: true });
2015
+ const entries = await fs6.readdir(parentDir, { withFileTypes: true });
1621
2016
  for (const entry of entries) {
1622
2017
  if (entry.isDirectory())
1623
2018
  continue;
@@ -1631,19 +2026,21 @@ var ContentStore = class {
1631
2026
  throw err;
1632
2027
  }
1633
2028
  try {
1634
- await fs4.link(currentPath, newPath);
2029
+ await fs6.link(currentPath, newPath);
1635
2030
  } catch (err) {
1636
2031
  if (isNodeError(err) && err.code === "EEXIST") {
1637
2032
  throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
1638
2033
  }
1639
2034
  throw err;
1640
2035
  }
1641
- await fs4.unlink(currentPath);
1642
- const newRelativePath = path5.relative(this.root, newPath);
1643
- const entryId = idIndex.findByPath(currentRelPath);
2036
+ await fs6.unlink(currentPath);
2037
+ const newRelativePath = path7.relative(this.root, newPath);
2038
+ const liveIndex = this._idIndex;
2039
+ const entryId = liveIndex.findByPath(currentRelPath);
1644
2040
  if (entryId) {
1645
- idIndex.updatePath(entryId, newRelativePath);
2041
+ liveIndex.updatePath(entryId, newRelativePath);
1646
2042
  }
2043
+ await this.recordOwnMutation(liveIndex);
1647
2044
  return { newPath: `${collectionPath}/${safeNewSlug}` };
1648
2045
  });
1649
2046
  }
@@ -1737,11 +2134,32 @@ var ContentStore = class {
1737
2134
  * Resolve a single reference ID to full entry data.
1738
2135
  * Returns null if the reference is invalid or missing.
1739
2136
  * Includes id, slug, and collection fields for debugging.
2137
+ *
2138
+ * Suspicious results (ID missing from the index, or an index hit whose file
2139
+ * is gone) trigger one forced index refresh and a retry — self-healing for
2140
+ * mutations by other processes inside the marker's residual windows. The
2141
+ * forced refresh itself is throttled (see refreshIndexForSuspiciousLookup),
2142
+ * but the retry against the live index always runs regardless of whether
2143
+ * this call won the throttle race: when a `list: true` reference array is
2144
+ * resolved via Promise.all, every miss shares the same stale snapshot, and
2145
+ * idIndex() dedupes concurrent builds — so a sibling call that wins the
2146
+ * throttle and rebuilds heals every other miss in the same batch, not just
2147
+ * the first.
1740
2148
  */
1741
2149
  async resolveSingleReference(id, idIndex) {
2150
+ const first = await this.resolveSingleReferenceOnce(id, idIndex);
2151
+ if (first !== STALE_LOOKUP)
2152
+ return first;
2153
+ await this.refreshIndexForSuspiciousLookup();
2154
+ const second = await this.resolveSingleReferenceOnce(id, await this.idIndex());
2155
+ return second === STALE_LOOKUP ? null : second;
2156
+ }
2157
+ async resolveSingleReferenceOnce(id, idIndex) {
1742
2158
  try {
1743
2159
  const location = idIndex.findById(id);
1744
- if (!location || location.type !== "entry" || !location.collection || !location.slug) {
2160
+ if (!location)
2161
+ return STALE_LOOKUP;
2162
+ if (location.type !== "entry" || !location.collection || !location.slug) {
1745
2163
  return null;
1746
2164
  }
1747
2165
  const doc = await this.read(location.collection, location.slug, {
@@ -1754,6 +2172,8 @@ var ContentStore = class {
1754
2172
  ...doc.data
1755
2173
  };
1756
2174
  } catch (error) {
2175
+ if (isNodeError(error) && error.code === "ENOENT")
2176
+ return STALE_LOOKUP;
1757
2177
  console.error(`Failed to resolve reference ${id}:`, error);
1758
2178
  return null;
1759
2179
  }
@@ -1761,11 +2181,11 @@ var ContentStore = class {
1761
2181
  };
1762
2182
 
1763
2183
  // dist/branch-schema-cache.js
1764
- import fs6 from "node:fs/promises";
1765
- import path6 from "node:path";
2184
+ import fs8 from "node:fs/promises";
2185
+ import path8 from "node:path";
1766
2186
 
1767
2187
  // dist/schema/meta-loader.js
1768
- import { promises as fs5 } from "fs";
2188
+ import { promises as fs7 } from "fs";
1769
2189
  import { join as join2 } from "pathe";
1770
2190
  import { z as z6 } from "zod";
1771
2191
  import chokidar from "chokidar";
@@ -1801,7 +2221,7 @@ function stripEmbeddedIdFromName(name) {
1801
2221
  async function scanForCollectionMeta(baseDir, relativePath = "") {
1802
2222
  const collections = [];
1803
2223
  try {
1804
- const entries = await fs5.readdir(baseDir, { withFileTypes: true });
2224
+ const entries = await fs7.readdir(baseDir, { withFileTypes: true });
1805
2225
  for (const entry of entries) {
1806
2226
  if (!entry.isDirectory())
1807
2227
  continue;
@@ -1812,8 +2232,8 @@ async function scanForCollectionMeta(baseDir, relativePath = "") {
1812
2232
  const absolutePath = join2(baseDir, folderName);
1813
2233
  const metaPath = join2(absolutePath, ".collection.json");
1814
2234
  try {
1815
- await fs5.access(metaPath);
1816
- const content = await fs5.readFile(metaPath, "utf-8");
2235
+ await fs7.access(metaPath);
2236
+ const content = await fs7.readFile(metaPath, "utf-8");
1817
2237
  const parsed = JSON.parse(content);
1818
2238
  const meta = collectionMetaSchema.parse(parsed);
1819
2239
  collections.push({
@@ -1845,7 +2265,7 @@ async function loadCollectionMetaFiles(contentRoot) {
1845
2265
  let root = null;
1846
2266
  const rootMetaPath = join2(contentRoot, ".collection.json");
1847
2267
  try {
1848
- await fs5.access(rootMetaPath);
2268
+ await fs7.access(rootMetaPath);
1849
2269
  } catch (err) {
1850
2270
  if (err.code === "ENOENT") {
1851
2271
  } else {
@@ -1853,7 +2273,7 @@ async function loadCollectionMetaFiles(contentRoot) {
1853
2273
  }
1854
2274
  }
1855
2275
  try {
1856
- const content = await fs5.readFile(rootMetaPath, "utf-8");
2276
+ const content = await fs7.readFile(rootMetaPath, "utf-8");
1857
2277
  const parsed = JSON.parse(content);
1858
2278
  root = rootCollectionMetaSchema.parse(parsed);
1859
2279
  } catch (err) {
@@ -1971,16 +2391,16 @@ var MTIME_CHECK_DEBOUNCE_MS = 1e3;
1971
2391
  async function isStaleByMtime(dir, cachedAt) {
1972
2392
  let entries;
1973
2393
  try {
1974
- entries = await fs6.readdir(dir, { recursive: true, encoding: "utf-8" });
2394
+ entries = await fs8.readdir(dir, { recursive: true, encoding: "utf-8" });
1975
2395
  } catch {
1976
2396
  return true;
1977
2397
  }
1978
2398
  for (const entry of entries) {
1979
2399
  if (!entry.endsWith(".collection.json"))
1980
2400
  continue;
1981
- const full = path6.join(dir, entry);
2401
+ const full = path8.join(dir, entry);
1982
2402
  try {
1983
- const stat = await fs6.stat(full);
2403
+ const stat = await fs8.stat(full);
1984
2404
  if (stat.mtimeMs > cachedAt.getTime())
1985
2405
  return true;
1986
2406
  } catch {
@@ -2004,7 +2424,7 @@ var BranchSchemaCache = class {
2004
2424
  * the project root, regardless of which entrypoint produced the cwd branchRoot.
2005
2425
  */
2006
2426
  skipDiskCache(branchRoot) {
2007
- return isBuildMode() || path6.resolve(branchRoot) === path6.resolve(process.cwd());
2427
+ return isBuildMode() || path8.resolve(branchRoot) === path8.resolve(process.cwd());
2008
2428
  }
2009
2429
  /**
2010
2430
  * Get schema for a branch (loads from cache or resolves fresh).
@@ -2021,17 +2441,17 @@ var BranchSchemaCache = class {
2021
2441
  * Load schema from cache or resolve fresh if cache is missing or stale.
2022
2442
  */
2023
2443
  async loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName) {
2024
- const contentRoot = path6.join(branchRoot, contentRootName);
2444
+ const contentRoot = path8.join(branchRoot, contentRootName);
2025
2445
  const skipDiskCache = this.skipDiskCache(branchRoot);
2026
2446
  if (!skipDiskCache) {
2027
- const cacheDir = path6.join(branchRoot, ".canopy-meta");
2028
- const cachePath = path6.join(cacheDir, "schema-cache.json");
2029
- const stalePath = path6.join(cacheDir, "schema-cache.stale");
2447
+ const cacheDir = path8.join(branchRoot, ".canopy-meta");
2448
+ const cachePath = path8.join(cacheDir, "schema-cache.json");
2449
+ const stalePath = path8.join(cacheDir, "schema-cache.stale");
2030
2450
  let cacheData = null;
2031
2451
  try {
2032
- const staleExists = await fs6.access(stalePath).then(() => true).catch(() => false);
2452
+ const staleExists = await fs8.access(stalePath).then(() => true).catch(() => false);
2033
2453
  if (!staleExists) {
2034
- const cacheContent = await fs6.readFile(cachePath, "utf-8");
2454
+ const cacheContent = await fs8.readFile(cachePath, "utf-8");
2035
2455
  cacheData = JSON.parse(cacheContent);
2036
2456
  }
2037
2457
  } catch {
@@ -2056,21 +2476,21 @@ var BranchSchemaCache = class {
2056
2476
  }
2057
2477
  const flatSchema = flattenSchema(result.schema, contentRootName);
2058
2478
  if (!skipDiskCache) {
2059
- const cacheDir = path6.join(branchRoot, ".canopy-meta");
2060
- const cachePath = path6.join(cacheDir, "schema-cache.json");
2061
- const stalePath = path6.join(cacheDir, "schema-cache.stale");
2062
- await fs6.mkdir(cacheDir, { recursive: true });
2479
+ const cacheDir = path8.join(branchRoot, ".canopy-meta");
2480
+ const cachePath = path8.join(cacheDir, "schema-cache.json");
2481
+ const stalePath = path8.join(cacheDir, "schema-cache.stale");
2482
+ await fs8.mkdir(cacheDir, { recursive: true });
2063
2483
  const newCache = {
2064
2484
  version: SCHEMA_CACHE_VERSION,
2065
2485
  schema: result.schema,
2066
2486
  flatSchema,
2067
2487
  cachedAt: (/* @__PURE__ */ new Date()).toISOString()
2068
2488
  };
2069
- const tmpPath = path6.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
2070
- await fs6.writeFile(tmpPath, JSON.stringify(newCache, null, 2), "utf-8");
2071
- await fs6.rename(tmpPath, cachePath);
2489
+ const tmpPath = path8.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
2490
+ await fs8.writeFile(tmpPath, JSON.stringify(newCache, null, 2), "utf-8");
2491
+ await fs8.rename(tmpPath, cachePath);
2072
2492
  try {
2073
- await fs6.unlink(stalePath);
2493
+ await fs8.unlink(stalePath);
2074
2494
  } catch {
2075
2495
  }
2076
2496
  }
@@ -2084,16 +2504,16 @@ var BranchSchemaCache = class {
2084
2504
  async invalidate(branchRoot) {
2085
2505
  if (this.skipDiskCache(branchRoot))
2086
2506
  return;
2087
- const cacheDir = path6.join(branchRoot, ".canopy-meta");
2088
- const stalePath = path6.join(cacheDir, "schema-cache.stale");
2089
- await fs6.mkdir(cacheDir, { recursive: true });
2090
- await fs6.writeFile(stalePath, "", "utf-8");
2507
+ const cacheDir = path8.join(branchRoot, ".canopy-meta");
2508
+ const stalePath = path8.join(cacheDir, "schema-cache.stale");
2509
+ await fs8.mkdir(cacheDir, { recursive: true });
2510
+ await fs8.writeFile(stalePath, "", "utf-8");
2091
2511
  }
2092
2512
  };
2093
2513
 
2094
2514
  // dist/ai/generate.js
2095
- import path7 from "node:path";
2096
- import fs7 from "node:fs/promises";
2515
+ import path9 from "node:path";
2516
+ import fs9 from "node:fs/promises";
2097
2517
  import { minimatch } from "minimatch";
2098
2518
 
2099
2519
  // dist/ai/strip-mdx.js
@@ -2599,97 +3019,6 @@ function yamlValue(value) {
2599
3019
  return value;
2600
3020
  }
2601
3021
 
2602
- // dist/utils/debug.js
2603
- var LOG_LEVELS = {
2604
- DEBUG: 0,
2605
- INFO: 1,
2606
- WARN: 2,
2607
- ERROR: 3
2608
- };
2609
- var DebugLogger = class {
2610
- constructor(options = {}) {
2611
- this.timers = /* @__PURE__ */ new Map();
2612
- this.options = options;
2613
- }
2614
- shouldLog(level) {
2615
- const enabled = this.options.enabled ?? process.env.CANOPYCMS_DEBUG === "true";
2616
- if (!enabled)
2617
- return false;
2618
- const minLevel = this.options.minLevel ?? "DEBUG";
2619
- return LOG_LEVELS[level] >= LOG_LEVELS[minLevel];
2620
- }
2621
- formatMessage(level, category, message) {
2622
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2623
- const prefix = this.options.prefix ?? "CanopyCMS";
2624
- return `[${timestamp}] [${prefix}:${category}] [${level}] ${message}`;
2625
- }
2626
- debug(category, message, data) {
2627
- if (this.shouldLog("DEBUG")) {
2628
- console.log(this.formatMessage("DEBUG", category, message), data ?? "");
2629
- }
2630
- }
2631
- info(category, message, data) {
2632
- if (this.shouldLog("INFO")) {
2633
- console.log(this.formatMessage("INFO", category, message), data ?? "");
2634
- }
2635
- }
2636
- warn(category, message, data) {
2637
- if (this.shouldLog("WARN")) {
2638
- console.warn(this.formatMessage("WARN", category, message), data ?? "");
2639
- }
2640
- }
2641
- error(category, message, data) {
2642
- const msg = this.formatMessage("ERROR", category, message);
2643
- if (this.shouldLog("ERROR")) {
2644
- console.error(msg, data ?? "");
2645
- }
2646
- const throwOnError = this.options.throwOnError ?? false;
2647
- if (throwOnError) {
2648
- const errorMsg = data ? `${message}: ${JSON.stringify(data)}` : message;
2649
- throw new Error(errorMsg);
2650
- }
2651
- }
2652
- /**
2653
- * Start timing an operation
2654
- */
2655
- time(label) {
2656
- this.timers.set(label, Date.now());
2657
- }
2658
- /**
2659
- * End timing an operation and log the duration
2660
- */
2661
- timeEnd(category, label) {
2662
- const start = this.timers.get(label);
2663
- if (start === void 0) {
2664
- this.warn(category, `Timer '${label}' does not exist`);
2665
- return;
2666
- }
2667
- const duration = Date.now() - start;
2668
- this.timers.delete(label);
2669
- this.debug(category, `${label} completed`, { durationMs: duration });
2670
- return duration;
2671
- }
2672
- /**
2673
- * Wrap an async function with automatic timing
2674
- */
2675
- async timed(category, label, fn) {
2676
- this.time(label);
2677
- try {
2678
- return await fn();
2679
- } finally {
2680
- this.timeEnd(category, label);
2681
- }
2682
- }
2683
- };
2684
- function createDebugLogger(options) {
2685
- return new DebugLogger(options);
2686
- }
2687
- var testLogger = createDebugLogger({
2688
- enabled: process.env.E2E_DEBUG === "true",
2689
- prefix: "E2E",
2690
- throwOnError: false
2691
- });
2692
-
2693
3022
  // dist/utils/entry-url.js
2694
3023
  init_normalize();
2695
3024
  function computeEntryUrl(collection, slug, contentRoot) {
@@ -2704,12 +3033,12 @@ function computeEntryUrl(collection, slug, contentRoot) {
2704
3033
  if (slug && slug !== "index") {
2705
3034
  segments.push(slug);
2706
3035
  }
2707
- const path15 = segments.length > 0 ? `/${segments.join("/")}` : "/";
2708
- return path15.toLowerCase();
3036
+ const path17 = segments.length > 0 ? `/${segments.join("/")}` : "/";
3037
+ return path17.toLowerCase();
2709
3038
  }
2710
3039
 
2711
3040
  // dist/entry-link-resolver.js
2712
- var log = createDebugLogger({ prefix: "EntryLinks" });
3041
+ var log2 = createDebugLogger({ prefix: "EntryLinks" });
2713
3042
  var BASE58_CHAR = "[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]";
2714
3043
  var ENTRY_LINK_PATTERN = new RegExp(`entry:(${BASE58_CHAR}{12})(#[^\\s)>"']*)?`, "g");
2715
3044
  function resolveEntryUrl(location, contentRoot) {
@@ -2723,7 +3052,7 @@ function resolveEntryLinksInText(text, idIndex, contentRoot, customResolver) {
2723
3052
  return part.text.replace(ENTRY_LINK_PATTERN, (_match, id, anchor) => {
2724
3053
  const location = idIndex.findById(id);
2725
3054
  if (!location || location.type !== "entry" || !location.collection || !location.slug) {
2726
- log.warn("resolve", `Entry link target not found: entry:${id}`);
3055
+ log2.warn("resolve", `Entry link target not found: entry:${id}`);
2727
3056
  return anchor ?? "#";
2728
3057
  }
2729
3058
  let url;
@@ -2880,7 +3209,7 @@ async function processCollection(store, collection, flatSchema, contentRoot, con
2880
3209
  const listed = await store.getCollectionEntryPaths(collection.logicalPath);
2881
3210
  const directEntries = listed.filter((e) => e.collection === collection.logicalPath);
2882
3211
  for (const listEntry of directEntries) {
2883
- const entryTypeName = extractEntryTypeFromFilename(path7.basename(listEntry.relativePath));
3212
+ const entryTypeName = extractEntryTypeFromFilename(path9.basename(listEntry.relativePath));
2884
3213
  if (!entryTypeName)
2885
3214
  continue;
2886
3215
  if (config?.exclude?.entryTypes?.includes(entryTypeName))
@@ -2898,7 +3227,7 @@ async function processCollection(store, collection, flatSchema, contentRoot, con
2898
3227
  }
2899
3228
  if (config?.exclude?.where?.(aiEntry))
2900
3229
  continue;
2901
- await runEntryTransform(aiEntry, doc.absolutePath, extractIdFromFilename(path7.basename(listEntry.relativePath)), config);
3230
+ await runEntryTransform(aiEntry, doc.absolutePath, extractIdFromFilename(path9.basename(listEntry.relativePath)), config);
2902
3231
  entries.push(aiEntry);
2903
3232
  const entryFilePath = `${cleanPath}/${listEntry.slug}.md`;
2904
3233
  const entryMarkdown = entryToMarkdown(aiEntry, config);
@@ -2949,7 +3278,7 @@ async function processRootEntries(store, rootCollection, contentRoot, config, id
2949
3278
  const listed = await store.getCollectionEntryPaths(rootCollection.logicalPath);
2950
3279
  const directEntries = listed.filter((e) => e.collection === rootCollection.logicalPath);
2951
3280
  for (const listEntry of directEntries) {
2952
- const entryTypeName = extractEntryTypeFromFilename(path7.basename(listEntry.relativePath));
3281
+ const entryTypeName = extractEntryTypeFromFilename(path9.basename(listEntry.relativePath));
2953
3282
  if (!entryTypeName)
2954
3283
  continue;
2955
3284
  if (config?.exclude?.entryTypes?.includes(entryTypeName))
@@ -2967,7 +3296,7 @@ async function processRootEntries(store, rootCollection, contentRoot, config, id
2967
3296
  }
2968
3297
  if (config?.exclude?.where?.(aiEntry))
2969
3298
  continue;
2970
- await runEntryTransform(aiEntry, doc.absolutePath, extractIdFromFilename(path7.basename(listEntry.relativePath)), config);
3299
+ await runEntryTransform(aiEntry, doc.absolutePath, extractIdFromFilename(path9.basename(listEntry.relativePath)), config);
2971
3300
  entries.push(aiEntry);
2972
3301
  const entryFilePath = `${listEntry.slug}.md`;
2973
3302
  const entryMarkdown = entryToMarkdown(aiEntry, config);
@@ -3015,19 +3344,19 @@ function docToAIEntry(doc, slug, entryTypeName, entryTypeConfig, cleanCollection
3015
3344
  };
3016
3345
  }
3017
3346
  function makeReadSibling(dir) {
3018
- const resolvedDir = path7.resolve(dir);
3347
+ const resolvedDir = path9.resolve(dir);
3019
3348
  return async (name) => {
3020
- if (!name || name.includes("/") || name.includes("\\") || path7.isAbsolute(name) || hasTraversalSequence(name)) {
3349
+ if (!name || name.includes("/") || name.includes("\\") || path9.isAbsolute(name) || hasTraversalSequence(name)) {
3021
3350
  return null;
3022
3351
  }
3023
- const abs = path7.resolve(resolvedDir, name);
3024
- if (path7.dirname(abs) !== resolvedDir)
3352
+ const abs = path9.resolve(resolvedDir, name);
3353
+ if (path9.dirname(abs) !== resolvedDir)
3025
3354
  return null;
3026
3355
  try {
3027
- const stats = await fs7.lstat(abs);
3356
+ const stats = await fs9.lstat(abs);
3028
3357
  if (!stats.isFile())
3029
3358
  return null;
3030
- return await fs7.readFile(abs, "utf8");
3359
+ return await fs9.readFile(abs, "utf8");
3031
3360
  } catch (err) {
3032
3361
  if (isNodeError(err) && (err.code === "ENOENT" || err.code === "ENOTDIR"))
3033
3362
  return null;
@@ -3041,7 +3370,7 @@ async function runEntryTransform(entry, absolutePath, contentId, config) {
3041
3370
  return;
3042
3371
  const ctx = {
3043
3372
  contentId: contentId ?? "",
3044
- readSibling: makeReadSibling(path7.dirname(absolutePath))
3373
+ readSibling: makeReadSibling(path9.dirname(absolutePath))
3045
3374
  };
3046
3375
  try {
3047
3376
  const appended = await transform(entry, ctx);
@@ -3078,33 +3407,33 @@ function matchesBundleFilter(entry, filter, contentRoot) {
3078
3407
  }
3079
3408
 
3080
3409
  // dist/branch-workspace.js
3081
- import path12 from "node:path";
3410
+ import path14 from "node:path";
3082
3411
 
3083
3412
  // dist/branch-metadata.js
3084
- import { randomUUID } from "node:crypto";
3085
- import fs9 from "node:fs/promises";
3086
- import path9 from "node:path";
3413
+ import { randomUUID as randomUUID2 } from "node:crypto";
3414
+ import fs11 from "node:fs/promises";
3415
+ import path11 from "node:path";
3087
3416
 
3088
3417
  // dist/branch-registry.js
3089
- import fs8 from "node:fs/promises";
3090
- import path8 from "node:path";
3418
+ import fs10 from "node:fs/promises";
3419
+ import path10 from "node:path";
3091
3420
  var REGISTRY_FILE = "branches.json";
3092
3421
  var REGISTRY_STALE_FILE = "branches.stale.json";
3093
3422
  var REGISTRY_TEMP_FILE = "branches.tmp.json";
3094
3423
  var REGISTRY_VERSION = 1;
3095
3424
  var BranchRegistry = class {
3096
3425
  constructor(root) {
3097
- this.root = path8.resolve(root);
3098
- this.registryPath = path8.join(this.root, REGISTRY_FILE);
3099
- this.stalePath = path8.join(this.root, REGISTRY_STALE_FILE);
3100
- this.tempPath = path8.join(this.root, REGISTRY_TEMP_FILE);
3426
+ this.root = path10.resolve(root);
3427
+ this.registryPath = path10.join(this.root, REGISTRY_FILE);
3428
+ this.stalePath = path10.join(this.root, REGISTRY_STALE_FILE);
3429
+ this.tempPath = path10.join(this.root, REGISTRY_TEMP_FILE);
3101
3430
  }
3102
3431
  /**
3103
3432
  * Returns all branches. Uses cache if fresh, regenerates if stale.
3104
3433
  */
3105
3434
  async list() {
3106
3435
  try {
3107
- const raw = await fs8.readFile(this.registryPath, "utf8");
3436
+ const raw = await fs10.readFile(this.registryPath, "utf8");
3108
3437
  const parsed = JSON.parse(raw);
3109
3438
  if (!parsed.version || !Array.isArray(parsed.branches)) {
3110
3439
  return await this.regenerate();
@@ -3130,7 +3459,7 @@ var BranchRegistry = class {
3130
3459
  */
3131
3460
  async invalidate() {
3132
3461
  try {
3133
- await fs8.rename(this.registryPath, this.stalePath);
3462
+ await fs10.rename(this.registryPath, this.stalePath);
3134
3463
  } catch (err) {
3135
3464
  if (!isNotFoundError(err)) {
3136
3465
  throw err;
@@ -3144,20 +3473,20 @@ var BranchRegistry = class {
3144
3473
  async regenerate() {
3145
3474
  const branches = await this.scanBranchDirectories();
3146
3475
  const uniqueTempPath = `${this.tempPath}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
3147
- await fs8.mkdir(this.root, { recursive: true });
3476
+ await fs10.mkdir(this.root, { recursive: true });
3148
3477
  const snapshot = {
3149
3478
  version: REGISTRY_VERSION,
3150
3479
  branches
3151
3480
  };
3152
- await fs8.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
3481
+ await fs10.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
3153
3482
  try {
3154
- await fs8.rename(uniqueTempPath, this.registryPath);
3483
+ await fs10.rename(uniqueTempPath, this.registryPath);
3155
3484
  } catch (err) {
3156
- await fs8.unlink(uniqueTempPath).catch(() => {
3485
+ await fs10.unlink(uniqueTempPath).catch(() => {
3157
3486
  });
3158
3487
  throw err;
3159
3488
  }
3160
- await fs8.unlink(this.stalePath).catch(() => {
3489
+ await fs10.unlink(this.stalePath).catch(() => {
3161
3490
  });
3162
3491
  return branches;
3163
3492
  }
@@ -3167,12 +3496,12 @@ var BranchRegistry = class {
3167
3496
  async scanBranchDirectories() {
3168
3497
  const branches = [];
3169
3498
  try {
3170
- const entries = await fs8.readdir(this.root, { withFileTypes: true });
3499
+ const entries = await fs10.readdir(this.root, { withFileTypes: true });
3171
3500
  for (const entry of entries) {
3172
3501
  if (!entry.isDirectory() || entry.name.startsWith(".")) {
3173
3502
  continue;
3174
3503
  }
3175
- const branchRoot = path8.join(this.root, entry.name);
3504
+ const branchRoot = path10.join(this.root, entry.name);
3176
3505
  const meta = await BranchMetadataFileManager.loadOnly(branchRoot);
3177
3506
  if (meta) {
3178
3507
  branches.push({
@@ -3204,8 +3533,8 @@ var BranchMetadataConflictError = class extends Error {
3204
3533
  };
3205
3534
  var BranchMetadataFileManager = class _BranchMetadataFileManager {
3206
3535
  constructor(branchRoot, baseRoot) {
3207
- this.branchRoot = path9.resolve(branchRoot);
3208
- this.filePath = path9.join(this.branchRoot, BRANCH_META_DIR, BRANCH_META_FILE);
3536
+ this.branchRoot = path11.resolve(branchRoot);
3537
+ this.filePath = path11.join(this.branchRoot, BRANCH_META_DIR, BRANCH_META_FILE);
3209
3538
  this.baseRoot = baseRoot;
3210
3539
  }
3211
3540
  /**
@@ -3213,9 +3542,9 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
3213
3542
  * Use this for read-only access (e.g., in registry scanning or loadBranchContext).
3214
3543
  */
3215
3544
  static async loadOnly(branchRoot) {
3216
- const filePath = path9.join(path9.resolve(branchRoot), BRANCH_META_DIR, BRANCH_META_FILE);
3545
+ const filePath = path11.join(path11.resolve(branchRoot), BRANCH_META_DIR, BRANCH_META_FILE);
3217
3546
  try {
3218
- const raw = await fs9.readFile(filePath, "utf8");
3547
+ const raw = await fs11.readFile(filePath, "utf8");
3219
3548
  return JSON.parse(raw);
3220
3549
  } catch (err) {
3221
3550
  if (isNotFoundError(err)) {
@@ -3233,7 +3562,7 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
3233
3562
  }
3234
3563
  async load() {
3235
3564
  try {
3236
- const raw = await fs9.readFile(this.filePath, "utf8");
3565
+ const raw = await fs11.readFile(this.filePath, "utf8");
3237
3566
  const parsed = JSON.parse(raw);
3238
3567
  const version = parsed.version ?? 0;
3239
3568
  return { meta: parsed, version };
@@ -3250,38 +3579,38 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
3250
3579
  */
3251
3580
  async write(meta, expectedVersion) {
3252
3581
  const newVersion = expectedVersion === null ? 1 : expectedVersion + 1;
3253
- const writeId = randomUUID();
3582
+ const writeId = randomUUID2();
3254
3583
  const payload = {
3255
3584
  ...meta,
3256
3585
  schemaVersion: meta.schemaVersion ?? CURRENT_SCHEMA_VERSION,
3257
3586
  version: newVersion,
3258
3587
  writeId
3259
3588
  };
3260
- await fs9.mkdir(path9.dirname(this.filePath), { recursive: true });
3589
+ await fs11.mkdir(path11.dirname(this.filePath), { recursive: true });
3261
3590
  const content = JSON.stringify(payload, null, 2) + "\n";
3262
3591
  if (expectedVersion === null) {
3263
3592
  const tempPath2 = `${this.filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
3264
- await fs9.writeFile(tempPath2, content, "utf-8");
3593
+ await fs11.writeFile(tempPath2, content, "utf-8");
3265
3594
  try {
3266
- await fs9.link(tempPath2, this.filePath);
3595
+ await fs11.link(tempPath2, this.filePath);
3267
3596
  } catch (err) {
3268
- await fs9.unlink(tempPath2).catch(() => {
3597
+ await fs11.unlink(tempPath2).catch(() => {
3269
3598
  });
3270
3599
  if (isFileExistsError(err)) {
3271
3600
  throw new BranchMetadataConflictError();
3272
3601
  }
3273
3602
  throw err;
3274
3603
  }
3275
- await fs9.unlink(tempPath2).catch(() => {
3604
+ await fs11.unlink(tempPath2).catch(() => {
3276
3605
  });
3277
3606
  return { version: newVersion, writeId };
3278
3607
  }
3279
3608
  const tempPath = `${this.filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
3280
- await fs9.writeFile(tempPath, content, "utf-8");
3609
+ await fs11.writeFile(tempPath, content, "utf-8");
3281
3610
  try {
3282
3611
  let currentVersion = null;
3283
3612
  try {
3284
- const current = JSON.parse(await fs9.readFile(this.filePath, "utf-8"));
3613
+ const current = JSON.parse(await fs11.readFile(this.filePath, "utf-8"));
3285
3614
  currentVersion = current.version ?? 0;
3286
3615
  } catch {
3287
3616
  currentVersion = null;
@@ -3289,13 +3618,13 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
3289
3618
  if (currentVersion !== expectedVersion) {
3290
3619
  throw new BranchMetadataConflictError();
3291
3620
  }
3292
- await fs9.rename(tempPath, this.filePath);
3293
- const afterWrite = JSON.parse(await fs9.readFile(this.filePath, "utf-8"));
3621
+ await fs11.rename(tempPath, this.filePath);
3622
+ const afterWrite = JSON.parse(await fs11.readFile(this.filePath, "utf-8"));
3294
3623
  if (afterWrite.writeId !== writeId) {
3295
3624
  throw new BranchMetadataConflictError();
3296
3625
  }
3297
3626
  } catch (err) {
3298
- await fs9.unlink(tempPath).catch(() => {
3627
+ await fs11.unlink(tempPath).catch(() => {
3299
3628
  });
3300
3629
  throw err;
3301
3630
  }
@@ -3358,8 +3687,8 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
3358
3687
  * Invalidates the registry cache so next list() call regenerates from branch.json files.
3359
3688
  */
3360
3689
  async invalidateRegistry() {
3361
- const registry = new BranchRegistry(this.baseRoot);
3362
- await registry.invalidate();
3690
+ const registry2 = new BranchRegistry(this.baseRoot);
3691
+ await registry2.invalidate();
3363
3692
  }
3364
3693
  };
3365
3694
  var getBranchMetadataFileManager = (branchRoot, baseRoot) => {
@@ -3386,8 +3715,8 @@ var loadBranchContext = async (options) => {
3386
3715
  init_operating_mode();
3387
3716
 
3388
3717
  // dist/git-manager.js
3389
- import fs11 from "node:fs/promises";
3390
- import path11 from "node:path";
3718
+ import fs13 from "node:fs/promises";
3719
+ import path13 from "node:path";
3391
3720
  import { simpleGit as simpleGit2 } from "simple-git";
3392
3721
 
3393
3722
  // dist/utils/git.js
@@ -3411,21 +3740,41 @@ async function resolveBaseBranch(options) {
3411
3740
  }
3412
3741
 
3413
3742
  // dist/utils/provisioning-lock.js
3414
- import fs10 from "node:fs/promises";
3415
- import path10 from "node:path";
3743
+ import fs12 from "node:fs/promises";
3744
+ import path12 from "node:path";
3416
3745
  import lockfile from "proper-lockfile";
3417
3746
  async function acquireProvisioningLock(lockTargetDir, lockName) {
3418
- await fs10.mkdir(lockTargetDir, { recursive: true });
3747
+ await fs12.mkdir(lockTargetDir, { recursive: true });
3419
3748
  return lockfile.lock(lockTargetDir, {
3420
- lockfilePath: path10.join(lockTargetDir, lockName),
3749
+ lockfilePath: path12.join(lockTargetDir, lockName),
3421
3750
  retries: { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true },
3422
3751
  stale: 3e4
3423
3752
  });
3424
3753
  }
3425
3754
 
3426
3755
  // dist/git-manager.js
3427
- var log2 = createDebugLogger({ prefix: "GitManager" });
3756
+ var log3 = createDebugLogger({ prefix: "GitManager" });
3428
3757
  var remoteInitLocks = /* @__PURE__ */ new Map();
3758
+ async function ensureGitExcludePattern(repoPath, pattern) {
3759
+ const excludePath = path13.join(repoPath, ".git", "info", "exclude");
3760
+ await fs13.mkdir(path13.dirname(excludePath), { recursive: true });
3761
+ let content = "";
3762
+ try {
3763
+ content = await fs13.readFile(excludePath, "utf-8");
3764
+ } catch (err) {
3765
+ if (!isNotFoundError(err))
3766
+ throw err;
3767
+ }
3768
+ const lines = content.split("\n");
3769
+ if (lines.some((line) => line.trim() === pattern)) {
3770
+ log3.debug("git", "Pattern already in .git/info/exclude", { pattern });
3771
+ return;
3772
+ }
3773
+ const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
3774
+ const newContent = content + (needsLeadingNewline ? "\n" : "") + pattern + "\n";
3775
+ await fs13.writeFile(excludePath, newContent, "utf-8");
3776
+ log3.debug("git", "Added pattern to .git/info/exclude", { pattern });
3777
+ }
3429
3778
  var GitConflictError = class extends Error {
3430
3779
  constructor(conflictedFiles) {
3431
3780
  super(`Git conflict in ${conflictedFiles.length} file(s): ${conflictedFiles.join(", ")}`);
@@ -3435,21 +3784,22 @@ var GitConflictError = class extends Error {
3435
3784
  };
3436
3785
  var GitManager = class _GitManager {
3437
3786
  constructor(options, gitOptions) {
3438
- this.repoPath = path11.resolve(options.repoPath);
3787
+ this.repoPath = path13.resolve(options.repoPath);
3439
3788
  this.baseBranch = options.baseBranch ?? "main";
3440
3789
  this.remote = options.remote ?? "origin";
3790
+ this.skipIndexMarker = options.skipIndexMarker ?? false;
3441
3791
  this.git = simpleGit2({ baseDir: this.repoPath, ...gitOptions });
3442
- this.git.env("GIT_CEILING_DIRECTORIES", path11.dirname(this.repoPath));
3792
+ this.git.env("GIT_CEILING_DIRECTORIES", path13.dirname(this.repoPath));
3443
3793
  }
3444
3794
  static async cloneRepo(remoteUrl, targetPath, baseBranch = "main") {
3445
- log2.debug("git", "Cloning repository", {
3795
+ log3.debug("git", "Cloning repository", {
3446
3796
  remoteUrl,
3447
3797
  targetPath,
3448
3798
  baseBranch
3449
3799
  });
3450
3800
  const git = simpleGit2();
3451
3801
  await git.clone(remoteUrl, targetPath, ["--branch", baseBranch, "--single-branch"]);
3452
- log2.debug("git", "Clone complete");
3802
+ log3.debug("git", "Clone complete");
3453
3803
  }
3454
3804
  /**
3455
3805
  * Initializes a local bare git repository to simulate a remote for dev mode.
@@ -3469,30 +3819,30 @@ var GitManager = class _GitManager {
3469
3819
  static async ensureLocalSimulatedRemote(options) {
3470
3820
  const existingLock = remoteInitLocks.get(options.remotePath);
3471
3821
  if (existingLock) {
3472
- log2.debug("git", "Waiting for existing remote initialization", {
3822
+ log3.debug("git", "Waiting for existing remote initialization", {
3473
3823
  remotePath: options.remotePath
3474
3824
  });
3475
3825
  await existingLock;
3476
3826
  }
3477
- const lockPromise = log2.timed("git", "ensureLocalSimulatedRemote", async () => {
3827
+ const lockPromise = log3.timed("git", "ensureLocalSimulatedRemote", async () => {
3478
3828
  let releaseLock;
3479
3829
  try {
3480
- log2.debug("git", "Initializing local simulated remote", {
3830
+ log3.debug("git", "Initializing local simulated remote", {
3481
3831
  remotePath: options.remotePath,
3482
3832
  baseBranch: options.baseBranch
3483
3833
  });
3484
- const remoteParent = path11.dirname(options.remotePath);
3834
+ const remoteParent = path13.dirname(options.remotePath);
3485
3835
  releaseLock = await acquireProvisioningLock(remoteParent, ".remote-init.lock");
3486
3836
  let remoteExists = false;
3487
3837
  try {
3488
- const stat = await fs11.stat(options.remotePath);
3838
+ const stat = await fs13.stat(options.remotePath);
3489
3839
  remoteExists = stat.isDirectory();
3490
3840
  } catch (err) {
3491
3841
  if (!isNotFoundError(err))
3492
3842
  throw err;
3493
3843
  }
3494
3844
  if (remoteExists && await _GitManager.bareRemoteHasBranch(options.remotePath, options.baseBranch)) {
3495
- log2.debug("git", "Remote already has base branch, skipping");
3845
+ log3.debug("git", "Remote already has base branch, skipping");
3496
3846
  return;
3497
3847
  }
3498
3848
  let gitRoot = options.sourcePath;
@@ -3511,8 +3861,8 @@ var GitManager = class _GitManager {
3511
3861
  }
3512
3862
  let hasCommits = false;
3513
3863
  try {
3514
- const log4 = await sourceGit.log(["-1"]);
3515
- hasCommits = log4.total > 0;
3864
+ const log5 = await sourceGit.log(["-1"]);
3865
+ hasCommits = log5.total > 0;
3516
3866
  } catch {
3517
3867
  hasCommits = false;
3518
3868
  }
@@ -3524,12 +3874,12 @@ var GitManager = class _GitManager {
3524
3874
  throw new Error(`Cannot initialize local simulated remote: base branch '${options.baseBranch}' does not exist locally. Please checkout '${options.baseBranch}' first or provide an explicit remoteUrl.`);
3525
3875
  }
3526
3876
  if (remoteExists) {
3527
- log2.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
3877
+ log3.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
3528
3878
  remotePath: options.remotePath,
3529
3879
  baseBranch: options.baseBranch
3530
3880
  });
3531
3881
  } else {
3532
- log2.debug("git", "Creating bare remote repository");
3882
+ log3.debug("git", "Creating bare remote repository");
3533
3883
  await simpleGit2().raw([
3534
3884
  "init",
3535
3885
  "--bare",
@@ -3543,13 +3893,13 @@ var GitManager = class _GitManager {
3543
3893
  baseBranch: options.baseBranch,
3544
3894
  subdirectory: options.subdirectory
3545
3895
  });
3546
- log2.debug("git", "Remote initialization complete");
3896
+ log3.debug("git", "Remote initialization complete");
3547
3897
  } finally {
3548
3898
  if (releaseLock) {
3549
3899
  try {
3550
3900
  await releaseLock();
3551
3901
  } catch (err) {
3552
- log2.debug("git", "Failed to release remote-init lock", { err });
3902
+ log3.debug("git", "Failed to release remote-init lock", { err });
3553
3903
  }
3554
3904
  }
3555
3905
  remoteInitLocks.delete(options.remotePath);
@@ -3648,7 +3998,7 @@ var GitManager = class _GitManager {
3648
3998
  */
3649
3999
  static async validateGitRepoExists(repoPath) {
3650
4000
  try {
3651
- const stat = await fs11.stat(path11.join(repoPath, ".git"));
4001
+ const stat = await fs13.stat(path13.join(repoPath, ".git"));
3652
4002
  if (!stat.isDirectory()) {
3653
4003
  throw new Error(`Expected git repo at ${repoPath}`);
3654
4004
  }
@@ -3686,9 +4036,9 @@ var GitManager = class _GitManager {
3686
4036
  return process.env[config.envVarName];
3687
4037
  if (config.autoDetectRemotePath) {
3688
4038
  try {
3689
- const stat = await fs11.stat(config.autoDetectRemotePath);
4039
+ const stat = await fs13.stat(config.autoDetectRemotePath);
3690
4040
  if (stat.isDirectory()) {
3691
- log2.debug("git", "Auto-detected local remote", {
4041
+ log3.debug("git", "Auto-detected local remote", {
3692
4042
  path: config.autoDetectRemotePath
3693
4043
  });
3694
4044
  return config.autoDetectRemotePath;
@@ -3699,8 +4049,8 @@ var GitManager = class _GitManager {
3699
4049
  if (config.shouldAutoInitLocal) {
3700
4050
  const gitRoot = await this.findGitRoot();
3701
4051
  const sourceRoot = options.sourceRoot;
3702
- const sourcePath = sourceRoot ? path11.resolve(gitRoot, sourceRoot) : gitRoot;
3703
- const localRemotePath = path11.join(sourcePath, config.defaultRemotePath);
4052
+ const sourcePath = sourceRoot ? path13.resolve(gitRoot, sourceRoot) : gitRoot;
4053
+ const localRemotePath = path13.join(sourcePath, config.defaultRemotePath);
3704
4054
  await this.ensureLocalSimulatedRemote({
3705
4055
  remotePath: localRemotePath,
3706
4056
  sourcePath: gitRoot,
@@ -3726,24 +4076,24 @@ var GitManager = class _GitManager {
3726
4076
  const baseBranch = await resolveBaseBranch({
3727
4077
  defaultBaseBranch: options.baseBranch,
3728
4078
  mode: options.mode,
3729
- detectFrom: options.sourceRoot ? path11.resolve(process.cwd(), options.sourceRoot) : process.cwd()
4079
+ detectFrom: options.sourceRoot ? path13.resolve(process.cwd(), options.sourceRoot) : process.cwd()
3730
4080
  });
3731
4081
  const remoteName = options.remoteName ?? "origin";
3732
4082
  let repoExists = false;
3733
4083
  try {
3734
4084
  const checkGit = simpleGit2({ baseDir: options.workspacePath });
3735
- checkGit.env("GIT_CEILING_DIRECTORIES", path11.dirname(options.workspacePath));
4085
+ checkGit.env("GIT_CEILING_DIRECTORIES", path13.dirname(options.workspacePath));
3736
4086
  await checkGit.raw(["rev-parse", "--git-dir"]);
3737
4087
  repoExists = true;
3738
4088
  } catch {
3739
- const gitPath = path11.join(options.workspacePath, ".git");
4089
+ const gitPath = path13.join(options.workspacePath, ".git");
3740
4090
  try {
3741
- const stat = await fs11.stat(gitPath);
4091
+ const stat = await fs13.stat(gitPath);
3742
4092
  if (stat.isDirectory()) {
3743
- log2.debug("git", "Removing corrupt .git directory", {
4093
+ log3.debug("git", "Removing corrupt .git directory", {
3744
4094
  workspacePath: options.workspacePath
3745
4095
  });
3746
- await fs11.rm(gitPath, { recursive: true });
4096
+ await fs13.rm(gitPath, { recursive: true });
3747
4097
  }
3748
4098
  } catch (cleanupErr) {
3749
4099
  if (!isNotFoundError(cleanupErr))
@@ -3769,7 +4119,7 @@ var GitManager = class _GitManager {
3769
4119
  }
3770
4120
  justCloned = true;
3771
4121
  const freshGit = simpleGit2({ baseDir: options.workspacePath });
3772
- freshGit.env("GIT_CEILING_DIRECTORIES", path11.dirname(options.workspacePath));
4122
+ freshGit.env("GIT_CEILING_DIRECTORIES", path13.dirname(options.workspacePath));
3773
4123
  await freshGit.addConfig("canopycms.managed", "true");
3774
4124
  await freshGit.addConfig("user.name", options.gitBotAuthorName);
3775
4125
  await freshGit.addConfig("user.email", options.gitBotAuthorEmail);
@@ -3777,12 +4127,13 @@ var GitManager = class _GitManager {
3777
4127
  const git = new _GitManager({
3778
4128
  repoPath: options.workspacePath,
3779
4129
  baseBranch,
3780
- remote: remoteName
4130
+ remote: remoteName,
4131
+ skipIndexMarker: options.branchType === "orphan"
3781
4132
  });
3782
4133
  await git.git.addConfig("canopycms.managed", "true");
3783
4134
  await git.git.addConfig("user.name", options.gitBotAuthorName);
3784
4135
  await git.git.addConfig("user.email", options.gitBotAuthorEmail);
3785
- log2.debug("git", "Marked workspace as CanopyCMS-managed", {
4136
+ log3.debug("git", "Marked workspace as CanopyCMS-managed", {
3786
4137
  workspacePath: options.workspacePath
3787
4138
  });
3788
4139
  if (!justCloned) {
@@ -3817,7 +4168,33 @@ var GitManager = class _GitManager {
3817
4168
  tracking: s.tracking
3818
4169
  };
3819
4170
  }
4171
+ /**
4172
+ * Mark ContentStore ID indexes rooted at (or under) this repo as stale.
4173
+ * Called after operations that mutate the working tree (checkout/merge/rebase) so
4174
+ * ID→path lookups don't keep resolving to pre-mutation paths. Invoked in `finally`
4175
+ * blocks because even failed merges/rebases may have touched the tree before
4176
+ * aborting; over-invalidating is safe.
4177
+ *
4178
+ * Covers both scopes: in-process stores via the registry, and stores in OTHER
4179
+ * processes sharing the filesystem (worker vs Lambda on EFS) via the on-disk
4180
+ * generation marker — unless this manager targets a settings workspace
4181
+ * (skipIndexMarker), where only the free in-process invalidation runs.
4182
+ */
4183
+ async invalidateContentIndexes() {
4184
+ if (this.skipIndexMarker) {
4185
+ invalidateContentIndexesForRoot(this.repoPath);
4186
+ return;
4187
+ }
4188
+ await invalidateContentIndexesDurable(this.repoPath);
4189
+ }
3820
4190
  async checkoutBranch(branch) {
4191
+ try {
4192
+ await this.checkoutBranchInner(branch);
4193
+ } finally {
4194
+ await this.invalidateContentIndexes();
4195
+ }
4196
+ }
4197
+ async checkoutBranchInner(branch) {
3821
4198
  const branches = await this.git.branch();
3822
4199
  if (branches.all.includes(branch)) {
3823
4200
  await this.git.checkout(branch);
@@ -3841,6 +4218,13 @@ var GitManager = class _GitManager {
3841
4218
  }
3842
4219
  }
3843
4220
  async pullBase() {
4221
+ try {
4222
+ await this.pullBaseInner();
4223
+ } finally {
4224
+ await this.invalidateContentIndexes();
4225
+ }
4226
+ }
4227
+ async pullBaseInner() {
3844
4228
  await this.git.fetch(this.remote, this.baseBranch);
3845
4229
  try {
3846
4230
  await this.git.merge([`${this.remote}/${this.baseBranch}`]);
@@ -3861,6 +4245,13 @@ var GitManager = class _GitManager {
3861
4245
  }
3862
4246
  }
3863
4247
  async pullCurrentBranch() {
4248
+ try {
4249
+ await this.pullCurrentBranchInner();
4250
+ } finally {
4251
+ await this.invalidateContentIndexes();
4252
+ }
4253
+ }
4254
+ async pullCurrentBranchInner() {
3864
4255
  const branches = await this.git.branch();
3865
4256
  const currentBranch = branches.current;
3866
4257
  await this.git.fetch(this.remote, currentBranch);
@@ -3883,6 +4274,13 @@ var GitManager = class _GitManager {
3883
4274
  }
3884
4275
  }
3885
4276
  async rebaseOntoBase() {
4277
+ try {
4278
+ await this.rebaseOntoBaseInner();
4279
+ } finally {
4280
+ await this.invalidateContentIndexes();
4281
+ }
4282
+ }
4283
+ async rebaseOntoBaseInner() {
3886
4284
  await this.git.fetch(this.remote, this.baseBranch);
3887
4285
  try {
3888
4286
  await this.git.rebase([`${this.remote}/${this.baseBranch}`]);
@@ -3911,7 +4309,13 @@ var GitManager = class _GitManager {
3911
4309
  }
3912
4310
  async push(branch) {
3913
4311
  const target = branch ?? await this.git.revparse(["--abbrev-ref", "HEAD"]);
3914
- await this.git.push(this.remote, `${target}:${target}`, ["--set-upstream"]);
4312
+ await this.git.raw([
4313
+ "push",
4314
+ "--set-upstream",
4315
+ "--end-of-options",
4316
+ this.remote,
4317
+ `${target}:${target}`
4318
+ ]);
3915
4319
  }
3916
4320
  async ensureAuthor(author) {
3917
4321
  const config = await this.git.listConfig();
@@ -3965,7 +4369,7 @@ var GitManager = class _GitManager {
3965
4369
  */
3966
4370
  async forcePush(branch) {
3967
4371
  const target = branch ?? await this.git.revparse(["--abbrev-ref", "HEAD"]);
3968
- await this.git.push(this.remote, target, ["--force-with-lease"]);
4372
+ await this.git.raw(["push", "--force-with-lease", "--end-of-options", this.remote, target]);
3969
4373
  }
3970
4374
  /**
3971
4375
  * Get remote URL for current repo
@@ -3985,24 +4389,7 @@ var GitManager = class _GitManager {
3985
4389
  * This is idempotent - if the pattern already exists, it won't be added again.
3986
4390
  */
3987
4391
  async ensureGitExclude(pattern) {
3988
- const excludePath = path11.join(this.repoPath, ".git", "info", "exclude");
3989
- await fs11.mkdir(path11.dirname(excludePath), { recursive: true });
3990
- let content = "";
3991
- try {
3992
- content = await fs11.readFile(excludePath, "utf-8");
3993
- } catch (err) {
3994
- if (!isNotFoundError(err))
3995
- throw err;
3996
- }
3997
- const lines = content.split("\n");
3998
- if (lines.some((line) => line.trim() === pattern)) {
3999
- log2.debug("git", "Pattern already in .git/info/exclude", { pattern });
4000
- return;
4001
- }
4002
- const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
4003
- const newContent = content + (needsLeadingNewline ? "\n" : "") + pattern + "\n";
4004
- await fs11.writeFile(excludePath, newContent, "utf-8");
4005
- log2.debug("git", "Added pattern to .git/info/exclude", { pattern });
4392
+ await ensureGitExcludePattern(this.repoPath, pattern);
4006
4393
  }
4007
4394
  /**
4008
4395
  * Create an orphan branch for settings (permissions/groups).
@@ -4010,16 +4397,24 @@ var GitManager = class _GitManager {
4010
4397
  * Orphan branches have no shared history with other branches - they start fresh.
4011
4398
  * This is perfect for deployment-specific settings that shouldn't pollute content history.
4012
4399
  *
4013
- * The branch contains only settings files in .canopy-meta/ (groups.json, permissions.json).
4400
+ * The branch contains only settings files committed by explicit path
4401
+ * (e.g. permissions.json, groups.json at the workspace root).
4014
4402
  *
4015
4403
  * @param branchName - Name of the orphan branch (e.g., 'canopycms-settings-prod')
4016
4404
  * @param initialFiles - Files to commit to the new branch (e.g., { 'permissions.json': '{}', 'groups.json': '{}' })
4017
4405
  */
4018
4406
  async createOrphanSettingsBranch(branchName, initialFiles) {
4019
- log2.debug("git", "Creating orphan settings branch", { branchName });
4407
+ try {
4408
+ await this.createOrphanSettingsBranchInner(branchName, initialFiles);
4409
+ } finally {
4410
+ await this.invalidateContentIndexes();
4411
+ }
4412
+ }
4413
+ async createOrphanSettingsBranchInner(branchName, initialFiles) {
4414
+ log3.debug("git", "Creating orphan settings branch", { branchName });
4020
4415
  const branches = await this.git.branch();
4021
4416
  if (branches.all.includes(branchName)) {
4022
- log2.debug("git", "Orphan branch already exists", { branchName });
4417
+ log3.debug("git", "Orphan branch already exists", { branchName });
4023
4418
  await this.git.checkout(branchName);
4024
4419
  return;
4025
4420
  }
@@ -4029,25 +4424,25 @@ var GitManager = class _GitManager {
4029
4424
  } catch {
4030
4425
  }
4031
4426
  for (const [filePath, content] of Object.entries(initialFiles)) {
4032
- const absolutePath = path11.join(this.repoPath, filePath);
4033
- await fs11.mkdir(path11.dirname(absolutePath), { recursive: true });
4034
- await fs11.writeFile(absolutePath, content, "utf-8");
4427
+ const absolutePath = path13.join(this.repoPath, filePath);
4428
+ await fs13.mkdir(path13.dirname(absolutePath), { recursive: true });
4429
+ await fs13.writeFile(absolutePath, content, "utf-8");
4035
4430
  await this.git.add(filePath);
4036
4431
  }
4037
4432
  await this.git.commit("Initialize settings branch", ["--allow-empty"]);
4038
- log2.debug("git", "Orphan settings branch created", { branchName });
4433
+ log3.debug("git", "Orphan settings branch created", { branchName });
4039
4434
  }
4040
4435
  };
4041
4436
 
4042
4437
  // dist/branch-workspace.js
4043
- var log3 = createDebugLogger({ prefix: "BranchWorkspace" });
4438
+ var log4 = createDebugLogger({ prefix: "BranchWorkspace" });
4044
4439
  var workspaceInitLocks = /* @__PURE__ */ new Map();
4045
4440
  var BranchWorkspaceManager = class {
4046
4441
  constructor(config) {
4047
4442
  this.config = config;
4048
4443
  }
4049
4444
  async ensureGitWorkspace(options) {
4050
- return log3.timed("workspace", "ensureGitWorkspace", async () => {
4445
+ return log4.timed("workspace", "ensureGitWorkspace", async () => {
4051
4446
  const existingLock = workspaceInitLocks.get(options.branchRoot);
4052
4447
  if (existingLock) {
4053
4448
  await existingLock;
@@ -4056,11 +4451,11 @@ var BranchWorkspaceManager = class {
4056
4451
  const lockPromise = (async () => {
4057
4452
  let releaseLock;
4058
4453
  try {
4059
- log3.debug("workspace", "Ensuring git workspace", {
4454
+ log4.debug("workspace", "Ensuring git workspace", {
4060
4455
  branchName: options.branchName,
4061
4456
  mode: options.mode
4062
4457
  });
4063
- releaseLock = await acquireProvisioningLock(path12.dirname(options.branchRoot), `.${path12.basename(options.branchRoot)}.init.lock`);
4458
+ releaseLock = await acquireProvisioningLock(path14.dirname(options.branchRoot), `.${path14.basename(options.branchRoot)}.init.lock`);
4064
4459
  await GitManager.initializeWorkspace({
4065
4460
  workspacePath: options.branchRoot,
4066
4461
  branchName: options.branchName,
@@ -4080,7 +4475,7 @@ var BranchWorkspaceManager = class {
4080
4475
  try {
4081
4476
  await releaseLock();
4082
4477
  } catch (err) {
4083
- log3.debug("workspace", "Failed to release workspace-init lock", { err });
4478
+ log4.debug("workspace", "Failed to release workspace-init lock", { err });
4084
4479
  }
4085
4480
  }
4086
4481
  workspaceInitLocks.delete(options.branchRoot);
@@ -4100,7 +4495,7 @@ var BranchWorkspaceManager = class {
4100
4495
  const baseBranch = await resolveBaseBranch({
4101
4496
  defaultBaseBranch: this.config.defaultBaseBranch,
4102
4497
  mode,
4103
- detectFrom: this.config.sourceRoot ? path12.resolve(process.cwd(), this.config.sourceRoot) : void 0
4498
+ detectFrom: this.config.sourceRoot ? path14.resolve(process.cwd(), this.config.sourceRoot) : void 0
4104
4499
  });
4105
4500
  await this.ensureGitWorkspace({
4106
4501
  branchRoot,
@@ -4204,15 +4599,15 @@ async function generateAIContentFiles(options) {
4204
4599
  config: aiConfig,
4205
4600
  entryLinkUrl: config.entryLinkUrl
4206
4601
  });
4207
- const absoluteOutputDir = path13.resolve(outputDir) + path13.sep;
4602
+ const absoluteOutputDir = path15.resolve(outputDir) + path15.sep;
4208
4603
  let fileCount = 0;
4209
4604
  for (const [filePath, content] of result.files) {
4210
- const absolutePath = path13.resolve(path13.join(absoluteOutputDir, filePath));
4605
+ const absolutePath = path15.resolve(path15.join(absoluteOutputDir, filePath));
4211
4606
  if (!absolutePath.startsWith(absoluteOutputDir)) {
4212
4607
  throw new Error(`Path traversal detected in AI content output: ${filePath}`);
4213
4608
  }
4214
- await fs12.mkdir(path13.dirname(absolutePath), { recursive: true });
4215
- await fs12.writeFile(absolutePath, content, "utf-8");
4609
+ await fs14.mkdir(path15.dirname(absolutePath), { recursive: true });
4610
+ await fs14.writeFile(absolutePath, content, "utf-8");
4216
4611
  fileCount++;
4217
4612
  }
4218
4613
  return { fileCount, outputDir: absoluteOutputDir };
@@ -4223,7 +4618,7 @@ var jiti = createJiti(import.meta.url);
4223
4618
  async function generateAIContentCLI(options) {
4224
4619
  const { projectDir, outputDir = "public/ai", configPath, appDir = "app" } = options;
4225
4620
  console.log("\nCanopyCMS generate-ai-content\n");
4226
- const canopyConfigPath = path14.join(projectDir, "canopycms.config.ts");
4621
+ const canopyConfigPath = path16.join(projectDir, "canopycms.config.ts");
4227
4622
  let canopyConfigModule;
4228
4623
  try {
4229
4624
  canopyConfigModule = await jiti.import(canopyConfigPath);
@@ -4234,7 +4629,7 @@ async function generateAIContentCLI(options) {
4234
4629
  }
4235
4630
  const configExport = canopyConfigModule.default ?? canopyConfigModule.config ?? canopyConfigModule;
4236
4631
  const serverConfig = typeof configExport === "object" && configExport !== null && "server" in configExport ? configExport.server : configExport;
4237
- const schemasPath = path14.join(projectDir, appDir, "schemas.ts");
4632
+ const schemasPath = path16.join(projectDir, appDir, "schemas.ts");
4238
4633
  let entrySchemaRegistry = {};
4239
4634
  try {
4240
4635
  const schemasModule = await jiti.import(schemasPath);
@@ -4245,7 +4640,7 @@ async function generateAIContentCLI(options) {
4245
4640
  let aiConfig;
4246
4641
  if (configPath) {
4247
4642
  try {
4248
- const aiConfigModule = await jiti.import(path14.resolve(configPath));
4643
+ const aiConfigModule = await jiti.import(path16.resolve(configPath));
4249
4644
  aiConfig = aiConfigModule.aiContentConfig ?? aiConfigModule.default ?? aiConfigModule.config;
4250
4645
  } catch (err) {
4251
4646
  console.error(`Could not load AI config from ${configPath}`);
@@ -4262,7 +4657,7 @@ async function generateAIContentCLI(options) {
4262
4657
  console.error("Make sure canopycms.config.ts uses defineCanopyConfig().");
4263
4658
  process.exit(1);
4264
4659
  }
4265
- const resolvedOutput = path14.resolve(projectDir, outputDir);
4660
+ const resolvedOutput = path16.resolve(projectDir, outputDir);
4266
4661
  console.log(` Output: ${resolvedOutput}`);
4267
4662
  console.log(` Mode: ${serverConfig.mode ?? "dev"}`);
4268
4663
  const result = await generateAIContentFiles({