canopycms 0.0.61 → 0.0.62

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 (184) hide show
  1. package/dist/ai/json-to-markdown.js +1 -2
  2. package/dist/ai/json-to-markdown.js.map +1 -1
  3. package/dist/api/__test__/mock-client.d.ts +5 -1
  4. package/dist/api/__test__/mock-client.d.ts.map +1 -1
  5. package/dist/api/__test__/mock-client.js +9 -2
  6. package/dist/api/__test__/mock-client.js.map +1 -1
  7. package/dist/api/admin-branch-health.d.ts +36 -1
  8. package/dist/api/admin-branch-health.d.ts.map +1 -1
  9. package/dist/api/admin-branch-health.js +176 -2
  10. package/dist/api/admin-branch-health.js.map +1 -1
  11. package/dist/api/admin.d.ts +8 -1
  12. package/dist/api/admin.d.ts.map +1 -1
  13. package/dist/api/admin.js.map +1 -1
  14. package/dist/api/branch.d.ts.map +1 -1
  15. package/dist/api/branch.js +16 -2
  16. package/dist/api/branch.js.map +1 -1
  17. package/dist/api/client.d.ts +5 -1
  18. package/dist/api/client.d.ts.map +1 -1
  19. package/dist/api/client.js +6 -0
  20. package/dist/api/client.js.map +1 -1
  21. package/dist/api/comments.d.ts +2 -2
  22. package/dist/api/content.d.ts +11 -5
  23. package/dist/api/content.d.ts.map +1 -1
  24. package/dist/api/content.js +79 -6
  25. package/dist/api/content.js.map +1 -1
  26. package/dist/api/entries.d.ts +10 -0
  27. package/dist/api/entries.d.ts.map +1 -1
  28. package/dist/api/entries.js +40 -15
  29. package/dist/api/entries.js.map +1 -1
  30. package/dist/api/schema.d.ts +4 -4
  31. package/dist/api/schema.d.ts.map +1 -1
  32. package/dist/api/schema.js +13 -13
  33. package/dist/api/schema.js.map +1 -1
  34. package/dist/assets/transform.d.ts.map +1 -1
  35. package/dist/assets/transform.js +19 -6
  36. package/dist/assets/transform.js.map +1 -1
  37. package/dist/branch-health.d.ts +13 -0
  38. package/dist/branch-health.d.ts.map +1 -1
  39. package/dist/branch-health.js +23 -0
  40. package/dist/branch-health.js.map +1 -1
  41. package/dist/cli/cli.js +858 -537
  42. package/dist/cli/generate-ai-content.js +760 -455
  43. package/dist/cli/init.js +25 -7
  44. package/dist/cli/template-files/Dockerfile.cms.template +19 -1
  45. package/dist/cli/template-files/canopycms.config.ts.template +7 -0
  46. package/dist/cli/template-files/cms-stack.ts.template +7 -1
  47. package/dist/client.d.ts +1 -0
  48. package/dist/client.d.ts.map +1 -1
  49. package/dist/client.js.map +1 -1
  50. package/dist/config/schemas/field.d.ts +15 -15
  51. package/dist/config/types.d.ts +2 -2
  52. package/dist/config/types.d.ts.map +1 -1
  53. package/dist/config/types.js +0 -1
  54. package/dist/config/types.js.map +1 -1
  55. package/dist/config/validation.d.ts +9 -0
  56. package/dist/config/validation.d.ts.map +1 -1
  57. package/dist/config/validation.js +14 -0
  58. package/dist/config/validation.js.map +1 -1
  59. package/dist/content-id-index.d.ts +97 -5
  60. package/dist/content-id-index.d.ts.map +1 -1
  61. package/dist/content-id-index.js +170 -18
  62. package/dist/content-id-index.js.map +1 -1
  63. package/dist/content-store.d.ts +81 -7
  64. package/dist/content-store.d.ts.map +1 -1
  65. package/dist/content-store.js +443 -258
  66. package/dist/content-store.js.map +1 -1
  67. package/dist/editor/BranchManager.d.ts.map +1 -1
  68. package/dist/editor/BranchManager.js +18 -6
  69. package/dist/editor/BranchManager.js.map +1 -1
  70. package/dist/editor/CanopyEditor.d.ts.map +1 -1
  71. package/dist/editor/CanopyEditor.js +2 -2
  72. package/dist/editor/CanopyEditor.js.map +1 -1
  73. package/dist/editor/CanopyEditorPage.d.ts +9 -1
  74. package/dist/editor/CanopyEditorPage.d.ts.map +1 -1
  75. package/dist/editor/CanopyEditorPage.js +9 -2
  76. package/dist/editor/CanopyEditorPage.js.map +1 -1
  77. package/dist/editor/Editor.d.ts +9 -1
  78. package/dist/editor/Editor.d.ts.map +1 -1
  79. package/dist/editor/Editor.js +58 -24
  80. package/dist/editor/Editor.js.map +1 -1
  81. package/dist/editor/FormRenderer.d.ts.map +1 -1
  82. package/dist/editor/FormRenderer.js +12 -0
  83. package/dist/editor/FormRenderer.js.map +1 -1
  84. package/dist/editor/admin/SystemHealthPanel.js +6 -2
  85. package/dist/editor/admin/SystemHealthPanel.js.map +1 -1
  86. package/dist/editor/components/EntryCreateModal.d.ts +9 -1
  87. package/dist/editor/components/EntryCreateModal.d.ts.map +1 -1
  88. package/dist/editor/components/EntryCreateModal.js +4 -1
  89. package/dist/editor/components/EntryCreateModal.js.map +1 -1
  90. package/dist/editor/fields/DateTimeField.d.ts +46 -0
  91. package/dist/editor/fields/DateTimeField.d.ts.map +1 -0
  92. package/dist/editor/fields/DateTimeField.js +66 -0
  93. package/dist/editor/fields/DateTimeField.js.map +1 -0
  94. package/dist/editor/fields/NumberField.d.ts +34 -0
  95. package/dist/editor/fields/NumberField.d.ts.map +1 -0
  96. package/dist/editor/fields/NumberField.js +53 -0
  97. package/dist/editor/fields/NumberField.js.map +1 -0
  98. package/dist/editor/fields/NumberListField.d.ts +25 -0
  99. package/dist/editor/fields/NumberListField.d.ts.map +1 -0
  100. package/dist/editor/fields/NumberListField.js +53 -0
  101. package/dist/editor/fields/NumberListField.js.map +1 -0
  102. package/dist/editor/hooks/useBranchManager.d.ts.map +1 -1
  103. package/dist/editor/hooks/useBranchManager.js +115 -26
  104. package/dist/editor/hooks/useBranchManager.js.map +1 -1
  105. package/dist/editor/hooks/useDraftManager.d.ts +8 -0
  106. package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
  107. package/dist/editor/hooks/useDraftManager.js +261 -21
  108. package/dist/editor/hooks/useDraftManager.js.map +1 -1
  109. package/dist/editor/hooks/useEntryManager.d.ts +21 -0
  110. package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
  111. package/dist/editor/hooks/useEntryManager.js +25 -2
  112. package/dist/editor/hooks/useEntryManager.js.map +1 -1
  113. package/dist/editor/theme.d.ts +8 -8
  114. package/dist/git-manager.d.ts +51 -7
  115. package/dist/git-manager.d.ts.map +1 -1
  116. package/dist/git-manager.js +105 -14
  117. package/dist/git-manager.js.map +1 -1
  118. package/dist/http/handler.d.ts.map +1 -1
  119. package/dist/http/handler.js +70 -27
  120. package/dist/http/handler.js.map +1 -1
  121. package/dist/http/router.d.ts.map +1 -1
  122. package/dist/http/router.js +40 -3
  123. package/dist/http/router.js.map +1 -1
  124. package/dist/operating-mode/deployment-name-fixtures.d.ts +29 -0
  125. package/dist/operating-mode/deployment-name-fixtures.d.ts.map +1 -0
  126. package/dist/operating-mode/deployment-name-fixtures.js +55 -0
  127. package/dist/operating-mode/deployment-name-fixtures.js.map +1 -0
  128. package/dist/operating-mode/deployment-name.d.ts +9 -0
  129. package/dist/operating-mode/deployment-name.d.ts.map +1 -1
  130. package/dist/operating-mode/deployment-name.js +9 -1
  131. package/dist/operating-mode/deployment-name.js.map +1 -1
  132. package/dist/operating-mode/index.d.ts +2 -1
  133. package/dist/operating-mode/index.d.ts.map +1 -1
  134. package/dist/operating-mode/index.js +4 -1
  135. package/dist/operating-mode/index.js.map +1 -1
  136. package/dist/operating-mode/mode-env.d.ts +58 -0
  137. package/dist/operating-mode/mode-env.d.ts.map +1 -0
  138. package/dist/operating-mode/mode-env.js +97 -0
  139. package/dist/operating-mode/mode-env.js.map +1 -0
  140. package/dist/resolve-canopy-user.d.ts +34 -0
  141. package/dist/resolve-canopy-user.d.ts.map +1 -0
  142. package/dist/resolve-canopy-user.js +55 -0
  143. package/dist/resolve-canopy-user.js.map +1 -0
  144. package/dist/schema/schema-store.d.ts +53 -2
  145. package/dist/schema/schema-store.d.ts.map +1 -1
  146. package/dist/schema/schema-store.js +92 -21
  147. package/dist/schema/schema-store.js.map +1 -1
  148. package/dist/server.d.ts +9 -0
  149. package/dist/server.d.ts.map +1 -1
  150. package/dist/server.js +9 -0
  151. package/dist/server.js.map +1 -1
  152. package/dist/services.d.ts.map +1 -1
  153. package/dist/services.js +40 -6
  154. package/dist/services.js.map +1 -1
  155. package/dist/settings-workspace.d.ts +40 -0
  156. package/dist/settings-workspace.d.ts.map +1 -1
  157. package/dist/settings-workspace.js +139 -151
  158. package/dist/settings-workspace.js.map +1 -1
  159. package/dist/types.d.ts +10 -0
  160. package/dist/types.d.ts.map +1 -1
  161. package/dist/utils/content-write-lock.d.ts +127 -0
  162. package/dist/utils/content-write-lock.d.ts.map +1 -0
  163. package/dist/utils/content-write-lock.js +170 -0
  164. package/dist/utils/content-write-lock.js.map +1 -0
  165. package/dist/utils/error.d.ts +1 -1
  166. package/dist/utils/error.js +1 -1
  167. package/dist/utils/git.d.ts +16 -0
  168. package/dist/utils/git.d.ts.map +1 -1
  169. package/dist/utils/git.js +22 -0
  170. package/dist/utils/git.js.map +1 -1
  171. package/dist/utils/sanitize-href.d.ts +28 -3
  172. package/dist/utils/sanitize-href.d.ts.map +1 -1
  173. package/dist/utils/sanitize-href.js +64 -6
  174. package/dist/utils/sanitize-href.js.map +1 -1
  175. package/dist/validation/entry-link-validator.d.ts +1 -1
  176. package/dist/validation/entry-link-validator.js +3 -3
  177. package/dist/validation/entry-link-validator.js.map +1 -1
  178. package/dist/validation/entry-validator.js +1 -1
  179. package/dist/validation/entry-validator.js.map +1 -1
  180. package/dist/worker/cms-worker.d.ts +13 -0
  181. package/dist/worker/cms-worker.d.ts.map +1 -1
  182. package/dist/worker/cms-worker.js +326 -264
  183. package/dist/worker/cms-worker.js.map +1 -1
  184. package/package.json +2 -2
@@ -9,11 +9,11 @@ var __export = (target, all) => {
9
9
  };
10
10
 
11
11
  // dist/paths/normalize.js
12
- function normalizeFilesystemPath(path19) {
13
- return path19.split(/[\\/]+/).filter(Boolean).join("/");
12
+ function normalizeFilesystemPath(path20) {
13
+ return path20.split(/[\\/]+/).filter(Boolean).join("/");
14
14
  }
15
- function hasTraversalSequence(path19) {
16
- const normalized = normalizeFilesystemPath(path19);
15
+ function hasTraversalSequence(path20) {
16
+ const normalized = normalizeFilesystemPath(path20);
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(path19) {
26
+ function trimSlashes(path20) {
27
27
  let start = 0;
28
- let end = path19.length;
29
- while (start < end && path19[start] === "/")
28
+ let end = path20.length;
29
+ while (start < end && path20[start] === "/")
30
30
  start++;
31
- while (end > start && path19[end - 1] === "/")
31
+ while (end > start && path20[end - 1] === "/")
32
32
  end--;
33
- return path19.slice(start, end);
33
+ return path20.slice(start, end);
34
34
  }
35
35
  var init_normalize = __esm({
36
36
  "dist/paths/normalize.js"() {
@@ -175,7 +175,6 @@ var init_types2 = __esm({
175
175
  "number",
176
176
  "boolean",
177
177
  "datetime",
178
- "rich-text",
179
178
  "markdown",
180
179
  "mdx",
181
180
  "code"
@@ -446,6 +445,40 @@ var init_config = __esm({
446
445
  }
447
446
  });
448
447
 
448
+ // dist/operating-mode/mode-env.js
449
+ function readModeEnv() {
450
+ if (typeof process === "undefined" || typeof process.env === "undefined")
451
+ return void 0;
452
+ const inBrowser = typeof window !== "undefined";
453
+ const name = inBrowser ? BROWSER_MODE_ENV_VAR : SERVER_MODE_ENV_VAR;
454
+ const raw = inBrowser ? process.env.NEXT_PUBLIC_CANOPY_MODE : process.env.CANOPY_MODE;
455
+ const value = raw?.trim();
456
+ return value ? { name, value } : void 0;
457
+ }
458
+ function resolveOperatingMode(configMode) {
459
+ const env = readModeEnv();
460
+ if (!env)
461
+ return configMode;
462
+ if (env.value !== "prod" && env.value !== "dev") {
463
+ throw new Error(`CanopyCMS: invalid ${env.name}=${JSON.stringify(env.value)}. It must be exactly "prod" or "dev". Refusing to fall back to the configured mode (${JSON.stringify(configMode)}): a typo here would silently deploy dev auth semantics.`);
464
+ }
465
+ if (env.value !== configMode && !warned) {
466
+ canopyLogWarn(`CanopyCMS: ${env.name}="${env.value}" overrides config.mode="${configMode}". The environment wins by design \u2014 it is the per-deployment value, while canopycms.config.ts is shared by local dev, the image build and the deployment. See operating-mode/mode-env.ts.`);
467
+ warned = true;
468
+ }
469
+ return env.value;
470
+ }
471
+ var SERVER_MODE_ENV_VAR, BROWSER_MODE_ENV_VAR, warned;
472
+ var init_mode_env = __esm({
473
+ "dist/operating-mode/mode-env.js"() {
474
+ "use strict";
475
+ init_logger();
476
+ SERVER_MODE_ENV_VAR = "CANOPY_MODE";
477
+ BROWSER_MODE_ENV_VAR = "NEXT_PUBLIC_CANOPY_MODE";
478
+ warned = false;
479
+ }
480
+ });
481
+
449
482
  // dist/config/validation.js
450
483
  var forEachReferenceField;
451
484
  var init_validation = __esm({
@@ -453,6 +486,7 @@ var init_validation = __esm({
453
486
  "use strict";
454
487
  init_config();
455
488
  init_flatten();
489
+ init_mode_env();
456
490
  forEachReferenceField = (fields, visit) => {
457
491
  if (!Array.isArray(fields))
458
492
  return;
@@ -572,9 +606,9 @@ var init_client_safe_strategy = __esm({
572
606
  function resolveDeploymentName(config, modeDefault) {
573
607
  const envValue = process.env.CANOPYCMS_DEPLOYMENT_NAME?.trim();
574
608
  const configValue = config.deploymentName;
575
- if (envValue && configValue && envValue !== configValue && !warned) {
609
+ if (envValue && configValue && envValue !== configValue && !warned2) {
576
610
  canopyLogWarn(`CanopyCMS: CANOPYCMS_DEPLOYMENT_NAME ("${envValue}") differs from config.deploymentName ("${configValue}") \u2014 using the env var (infra-stamped env wins over shared-repo config by design; see resolveDeploymentName's doc comment). Update config.deploymentName to match if this mismatch was unintentional.`);
577
- warned = true;
611
+ warned2 = true;
578
612
  }
579
613
  const resolved = envValue || configValue || modeDefault;
580
614
  if (!isValidDeploymentName(resolved)) {
@@ -583,12 +617,12 @@ function resolveDeploymentName(config, modeDefault) {
583
617
  }
584
618
  return resolved;
585
619
  }
586
- var warned, VALID_DEPLOYMENT_NAME, isValidDeploymentName;
620
+ var warned2, VALID_DEPLOYMENT_NAME, isValidDeploymentName;
587
621
  var init_deployment_name = __esm({
588
622
  "dist/operating-mode/deployment-name.js"() {
589
623
  "use strict";
590
624
  init_logger();
591
- warned = false;
625
+ warned2 = false;
592
626
  VALID_DEPLOYMENT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
593
627
  isValidDeploymentName = (name) => VALID_DEPLOYMENT_NAME.test(name) && !name.includes("..") && !name.endsWith(".") && !name.endsWith(".lock");
594
628
  }
@@ -638,7 +672,7 @@ var init_config2 = __esm({
638
672
  });
639
673
 
640
674
  // dist/operating-mode/client-unsafe-strategy.js
641
- import path6 from "node:path";
675
+ import path8 from "node:path";
642
676
  function operatingStrategy(mode) {
643
677
  const cached = strategyCache.get(mode);
644
678
  if (cached)
@@ -676,32 +710,32 @@ var init_client_unsafe_strategy = __esm({
676
710
  // - shouldCommit(), shouldPush()
677
711
  // Add client-unsafe methods (use Node.js APIs)
678
712
  getWorkspaceRoot(_sourceRoot) {
679
- return path6.resolve(process.env.CANOPYCMS_WORKSPACE_ROOT ?? DEFAULT_PROD_WORKSPACE);
713
+ return path8.resolve(process.env.CANOPYCMS_WORKSPACE_ROOT ?? DEFAULT_PROD_WORKSPACE);
680
714
  }
681
715
  getContentRoot(contentRoot, sourceRoot) {
682
- return path6.resolve(sourceRoot ?? process.cwd(), contentRoot);
716
+ return path8.resolve(sourceRoot ?? process.cwd(), contentRoot);
683
717
  }
684
718
  getContentBranchesRoot(sourceRoot) {
685
- return path6.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
719
+ return path8.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
686
720
  }
687
721
  getContentBranchRoot(branchName, sourceRoot) {
688
- return path6.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
722
+ return path8.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
689
723
  }
690
724
  getGitExcludePattern() {
691
725
  return ".canopy-meta/";
692
726
  }
693
727
  getPermissionsFilePath(root) {
694
- return path6.join(root, this.getPermissionsFileName());
728
+ return path8.join(root, this.getPermissionsFileName());
695
729
  }
696
730
  getGroupsFilePath(root) {
697
- return path6.join(root, this.getGroupsFileName());
731
+ return path8.join(root, this.getGroupsFileName());
698
732
  }
699
733
  getRemoteUrlConfig() {
700
734
  return {
701
735
  shouldAutoInitLocal: false,
702
736
  defaultRemotePath: "",
703
737
  envVarName: "CANOPYCMS_REMOTE_URL",
704
- autoDetectRemotePath: path6.join(this.getWorkspaceRoot(), "remote.git")
738
+ autoDetectRemotePath: path8.join(this.getWorkspaceRoot(), "remote.git")
705
739
  };
706
740
  }
707
741
  requiresExistingRepo() {
@@ -713,7 +747,7 @@ var init_client_unsafe_strategy = __esm({
713
747
  return `canopycms-settings-${resolveDeploymentName(config, "prod")}`;
714
748
  }
715
749
  getSettingsRoot(sourceRoot) {
716
- return path6.join(this.getWorkspaceRoot(sourceRoot), "settings");
750
+ return path8.join(this.getWorkspaceRoot(sourceRoot), "settings");
717
751
  }
718
752
  usesSeparateSettingsBranch() {
719
753
  return true;
@@ -730,25 +764,25 @@ var init_client_unsafe_strategy = __esm({
730
764
  DevStrategy = class extends DevClientSafeStrategy {
731
765
  // Inherits client-safe methods from DevClientSafeStrategy
732
766
  getWorkspaceRoot(sourceRoot) {
733
- return path6.resolve(sourceRoot ?? process.cwd(), ".canopy-dev");
767
+ return path8.resolve(sourceRoot ?? process.cwd(), ".canopy-dev");
734
768
  }
735
769
  getContentRoot(contentRoot, sourceRoot) {
736
- return path6.resolve(sourceRoot ?? process.cwd(), contentRoot);
770
+ return path8.resolve(sourceRoot ?? process.cwd(), contentRoot);
737
771
  }
738
772
  getContentBranchesRoot(sourceRoot) {
739
- return path6.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
773
+ return path8.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
740
774
  }
741
775
  getContentBranchRoot(branchName, sourceRoot) {
742
- return path6.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
776
+ return path8.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
743
777
  }
744
778
  getGitExcludePattern() {
745
779
  return ".canopy-meta/";
746
780
  }
747
781
  getPermissionsFilePath(root) {
748
- return path6.join(root, this.getPermissionsFileName());
782
+ return path8.join(root, this.getPermissionsFileName());
749
783
  }
750
784
  getGroupsFilePath(root) {
751
- return path6.join(root, this.getGroupsFileName());
785
+ return path8.join(root, this.getGroupsFileName());
752
786
  }
753
787
  getRemoteUrlConfig() {
754
788
  return {
@@ -766,7 +800,7 @@ var init_client_unsafe_strategy = __esm({
766
800
  return `canopycms-settings-${resolveDeploymentName(config, "local")}`;
767
801
  }
768
802
  getSettingsRoot(sourceRoot) {
769
- return path6.join(this.getWorkspaceRoot(sourceRoot), "settings");
803
+ return path8.join(this.getWorkspaceRoot(sourceRoot), "settings");
770
804
  }
771
805
  usesSeparateSettingsBranch() {
772
806
  return true;
@@ -784,11 +818,15 @@ var init_client_unsafe_strategy = __esm({
784
818
  // dist/operating-mode/index.js
785
819
  var operating_mode_exports = {};
786
820
  __export(operating_mode_exports, {
821
+ BROWSER_MODE_ENV_VAR: () => BROWSER_MODE_ENV_VAR,
822
+ SERVER_MODE_ENV_VAR: () => SERVER_MODE_ENV_VAR,
787
823
  clearClientStrategyCache: () => clearClientStrategyCache,
788
824
  clearStrategyCache: () => clearStrategyCache,
789
825
  clientOperatingStrategy: () => clientOperatingStrategy,
826
+ isValidDeploymentName: () => isValidDeploymentName,
790
827
  operatingStrategy: () => operatingStrategy,
791
- resolveDeploymentName: () => resolveDeploymentName
828
+ resolveDeploymentName: () => resolveDeploymentName,
829
+ resolveOperatingMode: () => resolveOperatingMode
792
830
  });
793
831
  var init_operating_mode = __esm({
794
832
  "dist/operating-mode/index.js"() {
@@ -796,20 +834,21 @@ var init_operating_mode = __esm({
796
834
  init_client_safe_strategy();
797
835
  init_client_unsafe_strategy();
798
836
  init_deployment_name();
837
+ init_mode_env();
799
838
  }
800
839
  });
801
840
 
802
841
  // dist/cli/generate-ai-content.js
803
- import path18 from "node:path";
842
+ import path19 from "node:path";
804
843
  import { createJiti } from "jiti";
805
844
 
806
845
  // dist/build/generate-ai-content.js
807
846
  import fs16 from "node:fs/promises";
808
- import path17 from "node:path";
847
+ import path18 from "node:path";
809
848
 
810
849
  // dist/content-store.js
811
- import fs8 from "node:fs/promises";
812
- import path8 from "node:path";
850
+ import fs9 from "node:fs/promises";
851
+ import path10 from "node:path";
813
852
  import matter from "gray-matter";
814
853
  import { parse as yamlParse, stringify as yamlStringify } from "yaml";
815
854
 
@@ -849,6 +888,94 @@ async function withLock(key, fn) {
849
888
  }
850
889
  }
851
890
 
891
+ // dist/utils/content-write-lock.js
892
+ import path3 from "node:path";
893
+
894
+ // dist/utils/error.js
895
+ function getErrorMessage(err) {
896
+ if (err instanceof Error) {
897
+ return err.message;
898
+ }
899
+ if (typeof err === "string") {
900
+ return err;
901
+ }
902
+ return String(err);
903
+ }
904
+ function isNodeError(err) {
905
+ return err instanceof Error && "code" in err;
906
+ }
907
+ function isNotFoundError(err) {
908
+ return isNodeError(err) && err.code === "ENOENT";
909
+ }
910
+ function isFileExistsError(err) {
911
+ return isNodeError(err) && err.code === "EEXIST";
912
+ }
913
+
914
+ // dist/utils/provisioning-lock.js
915
+ import fs2 from "node:fs/promises";
916
+ import path2 from "node:path";
917
+ import lockfile from "proper-lockfile";
918
+ async function acquireProvisioningLock(lockTargetDir2, lockName) {
919
+ await fs2.mkdir(lockTargetDir2, { recursive: true });
920
+ return lockfile.lock(lockTargetDir2, {
921
+ lockfilePath: path2.join(lockTargetDir2, lockName),
922
+ retries: { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true },
923
+ stale: 3e4
924
+ });
925
+ }
926
+ async function tryAcquireProvisioningLock(lockTargetDir2, lockName) {
927
+ await fs2.mkdir(lockTargetDir2, { recursive: true });
928
+ return lockfile.lock(lockTargetDir2, {
929
+ lockfilePath: path2.join(lockTargetDir2, lockName),
930
+ retries: 0,
931
+ stale: 3e4
932
+ });
933
+ }
934
+
935
+ // dist/utils/content-write-lock.js
936
+ var META_DIR = ".canopy-meta";
937
+ var CONTENT_WRITE_LOCK_NAME = "content-write.lock";
938
+ var DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2e3;
939
+ var ContentWriteLockBusyError = class extends Error {
940
+ constructor(message = "This branch is busy (syncing with the base branch, or another save is in flight); the change was not saved. Try again in a moment.") {
941
+ super(message);
942
+ this.name = "ContentWriteLockBusyError";
943
+ }
944
+ };
945
+ function lockTargetDir(branchRoot) {
946
+ return path3.join(path3.resolve(branchRoot), META_DIR);
947
+ }
948
+ var sleep = (ms) => new Promise((resolve2) => {
949
+ setTimeout(resolve2, ms);
950
+ });
951
+ function tryAcquireContentWriteLock(branchRoot) {
952
+ return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME);
953
+ }
954
+ async function acquireContentWriteLock(branchRoot, waitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS) {
955
+ const startedAt = Date.now();
956
+ for (let attempt = 0; ; attempt++) {
957
+ try {
958
+ return await tryAcquireContentWriteLock(branchRoot);
959
+ } catch (err) {
960
+ if (!isNodeError(err) || err.code !== "ELOCKED")
961
+ throw err;
962
+ const remaining = waitMs - (Date.now() - startedAt);
963
+ if (remaining <= 0)
964
+ throw new ContentWriteLockBusyError();
965
+ const base = Math.min(40 * 2 ** attempt, 250);
966
+ await sleep(Math.min(base * (0.5 + Math.random()), remaining));
967
+ }
968
+ }
969
+ }
970
+ async function withContentWriteLock(branchRoot, fn, waitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS) {
971
+ const release = await acquireContentWriteLock(branchRoot, waitMs);
972
+ try {
973
+ return await fn();
974
+ } finally {
975
+ await release();
976
+ }
977
+ }
978
+
852
979
  // dist/utils/flatten-group-fields.js
853
980
  function flattenGroupFields(fields) {
854
981
  const result = [];
@@ -872,8 +999,8 @@ function findBodyFieldName(fields) {
872
999
  }
873
1000
 
874
1001
  // dist/content-id-index.js
875
- import fs2 from "node:fs/promises";
876
- import path2 from "node:path";
1002
+ import fs3 from "node:fs/promises";
1003
+ import path4 from "node:path";
877
1004
 
878
1005
  // dist/id.js
879
1006
  import { generate } from "short-uuid";
@@ -894,27 +1021,8 @@ function generateId() {
894
1021
  }
895
1022
  var isValidId = isValidContentId;
896
1023
 
897
- // dist/utils/error.js
898
- function getErrorMessage(err) {
899
- if (err instanceof Error) {
900
- return err.message;
901
- }
902
- if (typeof err === "string") {
903
- return err;
904
- }
905
- return String(err);
906
- }
907
- function isNodeError(err) {
908
- return err instanceof Error && "code" in err;
909
- }
910
- function isNotFoundError(err) {
911
- return isNodeError(err) && err.code === "ENOENT";
912
- }
913
- function isFileExistsError(err) {
914
- return isNodeError(err) && err.code === "EEXIST";
915
- }
916
-
917
1024
  // dist/content-id-index.js
1025
+ init_logger();
918
1026
  var EMPTY_LOGICAL_PATH = "";
919
1027
  function toLogicalCollectionPath(physicalPath) {
920
1028
  if (physicalPath === ".")
@@ -926,32 +1034,61 @@ var ContentIdIndex = class {
926
1034
  this.idToLocation = /* @__PURE__ */ new Map();
927
1035
  this.pathToId = /* @__PURE__ */ new Map();
928
1036
  this.byCollection = /* @__PURE__ */ new Map();
929
- this.root = path2.resolve(root);
1037
+ this.duplicateIds = /* @__PURE__ */ new Map();
1038
+ this.root = path4.resolve(root);
930
1039
  }
931
1040
  /**
932
- * Build index by scanning filenames recursively.
933
- * Throws if duplicate IDs found (collision detection).
1041
+ * Build index by scanning filenames recursively. Duplicate embedded IDs
1042
+ * are quarantined, not thrown on -- see the class doc comment's
1043
+ * "Duplicate-ID quarantine" section and {@link getDuplicateIds}.
934
1044
  */
935
1045
  async buildFromFilenames(startPath = "") {
936
1046
  await this.scanDirectory(startPath);
937
1047
  }
1048
+ /** Insert a location into all three indexes. Caller must ensure no existing entry for this id. */
1049
+ insertLocation(id, location) {
1050
+ this.idToLocation.set(id, location);
1051
+ this.pathToId.set(location.relativePath, id);
1052
+ if (location.type === "entry" && location.collection) {
1053
+ if (!this.byCollection.has(location.collection)) {
1054
+ this.byCollection.set(location.collection, /* @__PURE__ */ new Set());
1055
+ }
1056
+ this.byCollection.get(location.collection).add(id);
1057
+ }
1058
+ }
1059
+ /** Remove a location from all three indexes -- used when a lexicographically-earlier duplicate displaces it. */
1060
+ evictLocation(location) {
1061
+ this.idToLocation.delete(location.id);
1062
+ this.pathToId.delete(location.relativePath);
1063
+ if (location.type === "entry" && location.collection) {
1064
+ const idSet = this.byCollection.get(location.collection);
1065
+ if (idSet) {
1066
+ idSet.delete(location.id);
1067
+ if (idSet.size === 0)
1068
+ this.byCollection.delete(location.collection);
1069
+ }
1070
+ }
1071
+ }
1072
+ /** Record a quarantined duplicate path for admin-facing health reporting (see getDuplicateIds). */
1073
+ recordDuplicate(id, droppedPath) {
1074
+ let dropped = this.duplicateIds.get(id);
1075
+ if (!dropped) {
1076
+ dropped = /* @__PURE__ */ new Set();
1077
+ this.duplicateIds.set(id, dropped);
1078
+ }
1079
+ dropped.add(droppedPath);
1080
+ }
938
1081
  async scanDirectory(relativePath) {
939
- const absoluteDir = path2.join(this.root, relativePath);
1082
+ const absoluteDir = path4.join(this.root, relativePath);
940
1083
  try {
941
- const entries = await fs2.readdir(absoluteDir, { withFileTypes: true });
1084
+ const entries = await fs3.readdir(absoluteDir, { withFileTypes: true });
942
1085
  for (const entry of entries) {
943
1086
  if (entry.name.startsWith(".") || entry.name === "_ids_") {
944
1087
  continue;
945
1088
  }
946
- const fullRelativePath = path2.join(relativePath, entry.name);
1089
+ const fullRelativePath = path4.join(relativePath, entry.name);
947
1090
  const id = extractIdFromFilename(entry.name);
948
1091
  if (id) {
949
- if (this.idToLocation.has(id)) {
950
- const existing = this.idToLocation.get(id);
951
- throw new Error(`ID collision detected: ${id}
952
- File 1: ${existing.relativePath}
953
- File 2: ${fullRelativePath}`);
954
- }
955
1092
  const location = {
956
1093
  id,
957
1094
  // already ContentId from extractIdFromFilename
@@ -961,17 +1098,27 @@ var ContentIdIndex = class {
961
1098
  };
962
1099
  if (!entry.isDirectory()) {
963
1100
  const slug = extractSlugFromFilename(entry.name);
964
- const physicalCollection = path2.dirname(fullRelativePath);
1101
+ const physicalCollection = path4.dirname(fullRelativePath);
965
1102
  const collectionPath = toLogicalCollectionPath(physicalCollection);
966
1103
  location.slug = slug;
967
1104
  location.collection = collectionPath;
968
- if (!this.byCollection.has(collectionPath)) {
969
- this.byCollection.set(collectionPath, /* @__PURE__ */ new Set());
1105
+ }
1106
+ const existing = this.idToLocation.get(id);
1107
+ if (existing) {
1108
+ const newWins = fullRelativePath < existing.relativePath;
1109
+ const kept = newWins ? fullRelativePath : existing.relativePath;
1110
+ const dropped = newWins ? existing.relativePath : fullRelativePath;
1111
+ if (newWins) {
1112
+ this.evictLocation(existing);
1113
+ this.recordDuplicate(id, existing.relativePath);
1114
+ this.insertLocation(id, location);
1115
+ } else {
1116
+ this.recordDuplicate(id, fullRelativePath);
970
1117
  }
971
- this.byCollection.get(collectionPath).add(id);
1118
+ canopyLogWarn(`[ContentIdIndex] Duplicate content ID ${id}: "${kept}" and "${dropped}" both embed this ID. Keeping "${kept}" for ID-based lookups (reads, references, listings); "${dropped}" is excluded from those lookups for now but has NOT been deleted -- it is still on disk at that path, and saves addressed to it are refused until this is resolved. An admin can resolve this via the repair-content-duplicates admin action for this branch, which archives "${dropped}" with a dot-prefixed name so future scans stop flagging it. Root: ${this.root}`);
1119
+ } else {
1120
+ this.insertLocation(id, location);
972
1121
  }
973
- this.idToLocation.set(id, location);
974
- this.pathToId.set(fullRelativePath, id);
975
1122
  }
976
1123
  if (entry.isDirectory()) {
977
1124
  await this.scanDirectory(fullRelativePath);
@@ -983,6 +1130,42 @@ var ContentIdIndex = class {
983
1130
  }
984
1131
  }
985
1132
  }
1133
+ /**
1134
+ * IDs discovered on more than one file during the last buildFromFilenames
1135
+ * scan. Empty unless the content tree currently has a duplicate-ID pair
1136
+ * (see the class doc comment). Consumed by branch-health.ts's admin scan
1137
+ * and by the repair-content-duplicates admin action
1138
+ * (api/admin-branch-health.ts).
1139
+ */
1140
+ getDuplicateIds() {
1141
+ const result = [];
1142
+ for (const id of this.duplicateIds.keys()) {
1143
+ const duplicate = this.getDuplicateFor(id);
1144
+ if (duplicate)
1145
+ result.push(duplicate);
1146
+ }
1147
+ return result;
1148
+ }
1149
+ /**
1150
+ * The quarantine record for ONE id, or null when that id is not
1151
+ * duplicated. The O(1) counterpart to {@link getDuplicateIds} for the
1152
+ * write path, which has to ask this question on every save whose resolved
1153
+ * path disagrees with the index (ContentStore.write()'s duplicate-ID
1154
+ * guard) and must not pay for materializing the whole list.
1155
+ */
1156
+ getDuplicateFor(id) {
1157
+ const dropped = this.duplicateIds.get(id);
1158
+ if (!dropped)
1159
+ return null;
1160
+ const kept = this.idToLocation.get(id);
1161
+ if (!kept)
1162
+ return null;
1163
+ return {
1164
+ id,
1165
+ keptPath: kept.relativePath,
1166
+ droppedPaths: Array.from(dropped).sort()
1167
+ };
1168
+ }
986
1169
  /**
987
1170
  * Forward lookup: ID → location (O(1))
988
1171
  */
@@ -1075,7 +1258,7 @@ var ContentIdIndex = class {
1075
1258
  * must already exist on disk (created by ContentStore).
1076
1259
  */
1077
1260
  add(location) {
1078
- const id = extractIdFromFilename(path2.basename(location.relativePath));
1261
+ const id = extractIdFromFilename(path4.basename(location.relativePath));
1079
1262
  if (!id) {
1080
1263
  throw new Error(`Cannot add location without ID in filename: ${location.relativePath}`);
1081
1264
  }
@@ -1132,8 +1315,8 @@ var ContentIdIndex = class {
1132
1315
  location.relativePath = newRelativePath;
1133
1316
  if (location.type === "entry") {
1134
1317
  const oldCollection = location.collection;
1135
- location.slug = extractSlugFromFilename(path2.basename(newRelativePath));
1136
- const physicalCollection = path2.dirname(newRelativePath);
1318
+ location.slug = extractSlugFromFilename(path4.basename(newRelativePath));
1319
+ const physicalCollection = path4.dirname(newRelativePath);
1137
1320
  location.collection = toLogicalCollectionPath(physicalCollection);
1138
1321
  if (oldCollection !== location.collection) {
1139
1322
  if (oldCollection) {
@@ -1175,7 +1358,7 @@ function extractIdFromFilename(filename) {
1175
1358
  }
1176
1359
  async function resolveCollectionPath(root, logicalPath) {
1177
1360
  const fs17 = await import("node:fs/promises");
1178
- const path19 = await import("node:path");
1361
+ const path20 = await import("node:path");
1179
1362
  const segments = logicalPath.split("/").filter(Boolean);
1180
1363
  let currentPath = root;
1181
1364
  for (const segment of segments) {
@@ -1188,7 +1371,7 @@ async function resolveCollectionPath(root, logicalPath) {
1188
1371
  return logicalName === segment.toLowerCase();
1189
1372
  });
1190
1373
  if (matchingDir) {
1191
- currentPath = path19.join(currentPath, matchingDir.name);
1374
+ currentPath = path20.join(currentPath, matchingDir.name);
1192
1375
  } else {
1193
1376
  return null;
1194
1377
  }
@@ -1239,7 +1422,7 @@ function extractSlugFromFilename(filename, entryTypeName) {
1239
1422
  }
1240
1423
 
1241
1424
  // dist/content-index-registry.js
1242
- import path3 from "node:path";
1425
+ import path5 from "node:path";
1243
1426
  var registry = /* @__PURE__ */ new Map();
1244
1427
  var finalization = new FinalizationRegistry(({ rootKey, ref }) => {
1245
1428
  const refs = registry.get(rootKey);
@@ -1250,7 +1433,7 @@ var finalization = new FinalizationRegistry(({ rootKey, ref }) => {
1250
1433
  registry.delete(rootKey);
1251
1434
  });
1252
1435
  function registerContentIndexForInvalidation(root, target) {
1253
- const rootKey = path3.resolve(root);
1436
+ const rootKey = path5.resolve(root);
1254
1437
  let refs = registry.get(rootKey);
1255
1438
  if (!refs) {
1256
1439
  refs = /* @__PURE__ */ new Set();
@@ -1261,8 +1444,8 @@ function registerContentIndexForInvalidation(root, target) {
1261
1444
  finalization.register(target, { rootKey, ref });
1262
1445
  }
1263
1446
  function invalidateContentIndexesForRoot(root) {
1264
- const rootKey = path3.resolve(root);
1265
- const prefix = rootKey + path3.sep;
1447
+ const rootKey = path5.resolve(root);
1448
+ const prefix = rootKey + path5.sep;
1266
1449
  for (const [storeRoot, refs] of registry) {
1267
1450
  if (storeRoot !== rootKey && !storeRoot.startsWith(prefix))
1268
1451
  continue;
@@ -1280,8 +1463,8 @@ function invalidateContentIndexesForRoot(root) {
1280
1463
  }
1281
1464
 
1282
1465
  // dist/resource-generation.js
1283
- import fs3 from "node:fs/promises";
1284
- import path4 from "node:path";
1466
+ import fs4 from "node:fs/promises";
1467
+ import path6 from "node:path";
1285
1468
  import { randomUUID } from "node:crypto";
1286
1469
 
1287
1470
  // dist/utils/debug.js
@@ -1388,9 +1571,9 @@ var testLogger = createDebugLogger({
1388
1571
 
1389
1572
  // dist/resource-generation.js
1390
1573
  var log = createDebugLogger({ prefix: "ResourceGeneration" });
1391
- var META_DIR = ".canopy-meta";
1574
+ var META_DIR2 = ".canopy-meta";
1392
1575
  function resourceGenerationPath(root, resource) {
1393
- return path4.resolve(root, META_DIR, `${resource}.generation`);
1576
+ return path6.resolve(root, META_DIR2, `${resource}.generation`);
1394
1577
  }
1395
1578
  async function bumpResourceGeneration(root, resource, options) {
1396
1579
  const token = randomUUID();
@@ -1408,7 +1591,7 @@ async function bumpResourceGeneration(root, resource, options) {
1408
1591
  }
1409
1592
  async function readResourceGeneration(root, resource) {
1410
1593
  try {
1411
- const token = await fs3.readFile(resourceGenerationPath(root, resource), "utf-8");
1594
+ const token = await fs4.readFile(resourceGenerationPath(root, resource), "utf-8");
1412
1595
  return { ok: true, token };
1413
1596
  } catch (err) {
1414
1597
  if (isNodeError(err) && err.code === "ENOENT")
@@ -1424,11 +1607,11 @@ function isGenerationCurrent(snapshotToken, read) {
1424
1607
  }
1425
1608
 
1426
1609
  // dist/branch-schema-cache.js
1427
- import fs5 from "node:fs/promises";
1428
- import path5 from "node:path";
1610
+ import fs6 from "node:fs/promises";
1611
+ import path7 from "node:path";
1429
1612
 
1430
1613
  // dist/schema/meta-loader.js
1431
- import { promises as fs4 } from "fs";
1614
+ import { promises as fs5 } from "fs";
1432
1615
  import { join } from "pathe";
1433
1616
  import { z } from "zod";
1434
1617
  import chokidar from "chokidar";
@@ -1465,7 +1648,7 @@ function stripEmbeddedIdFromName(name) {
1465
1648
  async function scanForCollectionMeta(baseDir, relativePath = "") {
1466
1649
  const collections = [];
1467
1650
  try {
1468
- const entries = await fs4.readdir(baseDir, { withFileTypes: true });
1651
+ const entries = await fs5.readdir(baseDir, { withFileTypes: true });
1469
1652
  for (const entry of entries) {
1470
1653
  if (!entry.isDirectory())
1471
1654
  continue;
@@ -1476,8 +1659,8 @@ async function scanForCollectionMeta(baseDir, relativePath = "") {
1476
1659
  const absolutePath = join(baseDir, folderName);
1477
1660
  const metaPath = join(absolutePath, ".collection.json");
1478
1661
  try {
1479
- await fs4.access(metaPath);
1480
- const content = await fs4.readFile(metaPath, "utf-8");
1662
+ await fs5.access(metaPath);
1663
+ const content = await fs5.readFile(metaPath, "utf-8");
1481
1664
  const parsed = JSON.parse(content);
1482
1665
  const meta = collectionMetaSchema.parse(parsed);
1483
1666
  collections.push({
@@ -1509,7 +1692,7 @@ async function loadCollectionMetaFiles(contentRoot) {
1509
1692
  let root = null;
1510
1693
  const rootMetaPath = join(contentRoot, ".collection.json");
1511
1694
  try {
1512
- await fs4.access(rootMetaPath);
1695
+ await fs5.access(rootMetaPath);
1513
1696
  } catch (err) {
1514
1697
  if (err.code === "ENOENT") {
1515
1698
  } else {
@@ -1517,7 +1700,7 @@ async function loadCollectionMetaFiles(contentRoot) {
1517
1700
  }
1518
1701
  }
1519
1702
  try {
1520
- const content = await fs4.readFile(rootMetaPath, "utf-8");
1703
+ const content = await fs5.readFile(rootMetaPath, "utf-8");
1521
1704
  const parsed = JSON.parse(content);
1522
1705
  root = rootCollectionMetaSchema.parse(parsed);
1523
1706
  } catch (err) {
@@ -1704,16 +1887,16 @@ var SCHEMA_GENERATION_RESOURCE = "schema";
1704
1887
  async function isStaleByMtime(dir, cachedAt) {
1705
1888
  let entries;
1706
1889
  try {
1707
- entries = await fs5.readdir(dir, { recursive: true, encoding: "utf-8" });
1890
+ entries = await fs6.readdir(dir, { recursive: true, encoding: "utf-8" });
1708
1891
  } catch {
1709
1892
  return true;
1710
1893
  }
1711
1894
  for (const entry of entries) {
1712
1895
  if (!entry.endsWith(".collection.json"))
1713
1896
  continue;
1714
- const full = path5.join(dir, entry);
1897
+ const full = path7.join(dir, entry);
1715
1898
  try {
1716
- const stat = await fs5.stat(full);
1899
+ const stat = await fs6.stat(full);
1717
1900
  if (stat.mtimeMs > cachedAt.getTime())
1718
1901
  return true;
1719
1902
  } catch {
@@ -1737,7 +1920,7 @@ var BranchSchemaCache = class {
1737
1920
  * the project root, regardless of which entrypoint produced the cwd branchRoot.
1738
1921
  */
1739
1922
  skipDiskCache(branchRoot) {
1740
- return isBuildMode() || path5.resolve(branchRoot) === path5.resolve(process.cwd());
1923
+ return isBuildMode() || path7.resolve(branchRoot) === path7.resolve(process.cwd());
1741
1924
  }
1742
1925
  /**
1743
1926
  * Get schema for a branch (loads from cache or resolves fresh).
@@ -1763,14 +1946,14 @@ var BranchSchemaCache = class {
1763
1946
  * Load schema from cache or resolve fresh if cache is missing or stale.
1764
1947
  */
1765
1948
  async loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName) {
1766
- const contentRoot = path5.join(branchRoot, contentRootName);
1949
+ const contentRoot = path7.join(branchRoot, contentRootName);
1767
1950
  const skipDiskCache = this.skipDiskCache(branchRoot);
1768
1951
  if (!skipDiskCache) {
1769
- const cacheDir = path5.join(branchRoot, ".canopy-meta");
1770
- const cachePath = path5.join(cacheDir, "schema-cache.json");
1952
+ const cacheDir = path7.join(branchRoot, ".canopy-meta");
1953
+ const cachePath = path7.join(cacheDir, "schema-cache.json");
1771
1954
  let cacheData = null;
1772
1955
  try {
1773
- const cacheContent = await fs5.readFile(cachePath, "utf-8");
1956
+ const cacheContent = await fs6.readFile(cachePath, "utf-8");
1774
1957
  cacheData = JSON.parse(cacheContent);
1775
1958
  } catch {
1776
1959
  cacheData = null;
@@ -1807,7 +1990,7 @@ var BranchSchemaCache = class {
1807
1990
  */
1808
1991
  async resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, options) {
1809
1992
  const { skipDiskCache } = options;
1810
- const contentRoot = path5.join(branchRoot, contentRootName);
1993
+ const contentRoot = path7.join(branchRoot, contentRootName);
1811
1994
  const read = skipDiskCache ? null : await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
1812
1995
  const result = await this.resolveFresh(contentRoot, entrySchemaRegistry);
1813
1996
  if (!isValidSchema(result.schema)) {
@@ -1820,9 +2003,9 @@ var BranchSchemaCache = class {
1820
2003
  }
1821
2004
  const flatSchema = flattenSchema(result.schema, contentRootName);
1822
2005
  if (!skipDiskCache) {
1823
- const cacheDir = path5.join(branchRoot, ".canopy-meta");
1824
- const cachePath = path5.join(cacheDir, "schema-cache.json");
1825
- await fs5.unlink(path5.join(cacheDir, "schema-cache.stale")).catch(() => {
2006
+ const cacheDir = path7.join(branchRoot, ".canopy-meta");
2007
+ const cachePath = path7.join(cacheDir, "schema-cache.json");
2008
+ await fs6.unlink(path7.join(cacheDir, "schema-cache.stale")).catch(() => {
1826
2009
  });
1827
2010
  if (read && read.ok) {
1828
2011
  const newCache = {
@@ -1832,13 +2015,13 @@ var BranchSchemaCache = class {
1832
2015
  cachedAt: (/* @__PURE__ */ new Date()).toISOString(),
1833
2016
  generation: read.token
1834
2017
  };
1835
- await fs5.mkdir(cacheDir, { recursive: true });
1836
- const tmpPath = path5.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
1837
- await fs5.writeFile(tmpPath, JSON.stringify(newCache, null, 2), "utf-8");
2018
+ await fs6.mkdir(cacheDir, { recursive: true });
2019
+ const tmpPath = path7.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
2020
+ await fs6.writeFile(tmpPath, JSON.stringify(newCache, null, 2), "utf-8");
1838
2021
  try {
1839
- await fs5.rename(tmpPath, cachePath);
2022
+ await fs6.rename(tmpPath, cachePath);
1840
2023
  } catch (err) {
1841
- await fs5.unlink(tmpPath).catch(() => {
2024
+ await fs6.unlink(tmpPath).catch(() => {
1842
2025
  });
1843
2026
  throw err;
1844
2027
  }
@@ -1910,10 +2093,10 @@ async function invalidateBranchContentCaches(root) {
1910
2093
  }
1911
2094
 
1912
2095
  // dist/utils/fs.js
1913
- import fs6 from "node:fs/promises";
2096
+ import fs7 from "node:fs/promises";
1914
2097
  async function filePathExists(filePath) {
1915
2098
  try {
1916
- await fs6.stat(filePath);
2099
+ await fs7.stat(filePath);
1917
2100
  return true;
1918
2101
  } catch (err) {
1919
2102
  if (isNotFoundError(err))
@@ -1959,8 +2142,8 @@ function validateAndNormalizePath(root, target) {
1959
2142
 
1960
2143
  // dist/paths/branch.js
1961
2144
  init_operating_mode();
1962
- import fs7 from "node:fs/promises";
1963
- import path7 from "node:path";
2145
+ import fs8 from "node:fs/promises";
2146
+ import path9 from "node:path";
1964
2147
 
1965
2148
  // dist/paths/branch-name.js
1966
2149
  function sanitizeBranchName(branchName) {
@@ -1983,11 +2166,11 @@ function resolveBranchPath(options) {
1983
2166
  const safeBranch = sanitizeBranchName(options.branchName);
1984
2167
  const strategy = operatingStrategy(options.mode);
1985
2168
  const baseRoot = resolveContentBranchesRoot(options.mode, options.basePathOverride);
1986
- const normalizedBase = path7.resolve(baseRoot);
1987
- const baseWithSep = normalizedBase.endsWith(path7.sep) ? normalizedBase : `${normalizedBase}${path7.sep}`;
2169
+ const normalizedBase = path9.resolve(baseRoot);
2170
+ const baseWithSep = normalizedBase.endsWith(path9.sep) ? normalizedBase : `${normalizedBase}${path9.sep}`;
1988
2171
  const branchRoot = strategy.getContentBranchRoot(safeBranch, options.basePathOverride);
1989
2172
  const withinBase = (target) => {
1990
- const resolved = path7.resolve(target);
2173
+ const resolved = path9.resolve(target);
1991
2174
  return resolved === normalizedBase || resolved.startsWith(baseWithSep);
1992
2175
  };
1993
2176
  if (!withinBase(branchRoot)) {
@@ -1997,7 +2180,7 @@ function resolveBranchPath(options) {
1997
2180
  }
1998
2181
  async function ensureBranchRoot(options) {
1999
2182
  const result = resolveBranchPath(options);
2000
- await fs7.mkdir(result.branchRoot, { recursive: true });
2183
+ await fs8.mkdir(result.branchRoot, { recursive: true });
2001
2184
  return result;
2002
2185
  }
2003
2186
 
@@ -2014,11 +2197,35 @@ var ContentStoreError = class extends Error {
2014
2197
  }
2015
2198
  };
2016
2199
  var ContentConflictError = class extends Error {
2017
- constructor() {
2018
- super("Content was modified by another editor");
2200
+ constructor(message = "Content was modified by another editor") {
2201
+ super(message);
2019
2202
  this.name = "ContentConflictError";
2020
2203
  }
2021
2204
  };
2205
+ var BranchSyncingError = class extends ContentConflictError {
2206
+ constructor(message) {
2207
+ super(message);
2208
+ this.name = "BranchSyncingError";
2209
+ }
2210
+ };
2211
+ var DuplicateContentIdError = class extends ContentConflictError {
2212
+ constructor(contentId, paths) {
2213
+ const sorted = Array.from(new Set(paths)).sort();
2214
+ super(
2215
+ // Names the STATE, not an action. The repair-content-duplicates
2216
+ // endpoint exists but nothing in the editor renders it, so telling an
2217
+ // editor "an admin can run X" sent them to an admin who could neither
2218
+ // run X nor see that the branch was affected. Say what is true; the
2219
+ // admin panel's read-only duplicate list (SystemHealthPanel) is the
2220
+ // diagnosis half, and the repair UI is tracked in
2221
+ // .claude/future-tasks/duplicate-content-id-repair-ui.md.
2222
+ `Content ID ${contentId} is on more than one file (${sorted.map((p) => `"${p}"`).join(" and ")}), so this save was refused rather than risk overwriting or deleting the wrong one. An administrator needs to resolve the duplicate on the server before this entry can be saved.`
2223
+ );
2224
+ this.name = "DuplicateContentIdError";
2225
+ this.contentId = contentId;
2226
+ this.paths = sorted;
2227
+ }
2228
+ };
2022
2229
  function getDefaultEntryType(entries) {
2023
2230
  if (!entries || entries.length === 0)
2024
2231
  return void 0;
@@ -2043,13 +2250,40 @@ var ContentStore = class {
2043
2250
  this.lastDiskProbeMs = 0;
2044
2251
  this.lastForcedRefreshMs = 0;
2045
2252
  this.indexBuild = null;
2046
- this.root = path8.resolve(root);
2253
+ this.root = path10.resolve(root);
2047
2254
  this.contentRootName = options.contentRootName || "content";
2048
2255
  this.indexFreshnessIntervalMs = options.indexFreshnessIntervalMs ?? DEFAULT_INDEX_FRESHNESS_INTERVAL_MS;
2256
+ this.contentWriteLockWaitMs = options.contentWriteLockWaitMs ?? DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS;
2049
2257
  this.schemaIndex = new Map(flatSchema.map((item) => [item.logicalPath, item]));
2050
2258
  this._idIndex = new ContentIdIndex(this.root);
2051
2259
  registerContentIndexForInvalidation(this.root, this);
2052
2260
  }
2261
+ /**
2262
+ * [SYNC-C1] Run a working-tree mutation under the branch's cross-host
2263
+ * content-write lock (utils/content-write-lock.ts), on top of the
2264
+ * in-process locks the callee takes for itself.
2265
+ *
2266
+ * The in-process mutex serializes writers inside ONE process; it says
2267
+ * nothing about the EC2 worker rebasing this same tree on shared EFS, which
2268
+ * destroys an in-flight save (`checkout --theirs` overwrites it and the
2269
+ * rebase then reports success; `rebase --abort` hard-resets it). This is the
2270
+ * layer that actually excludes the two.
2271
+ *
2272
+ * Reads deliberately do NOT take it -- an extra EFS round-trip per read is
2273
+ * not an acceptable cost, and reads cannot be destroyed by a rebase.
2274
+ *
2275
+ * Acquisition order is always content lock -> `withLock`, never the reverse,
2276
+ * so the two cannot deadlock.
2277
+ */
2278
+ async withContentWriteExclusion(fn) {
2279
+ try {
2280
+ return await withContentWriteLock(this.root, fn, this.contentWriteLockWaitMs);
2281
+ } catch (err) {
2282
+ if (err instanceof ContentWriteLockBusyError)
2283
+ throw new BranchSyncingError(err.message);
2284
+ throw err;
2285
+ }
2286
+ }
2053
2287
  /**
2054
2288
  * Mark the ID index stale so the next idIndex() access rebuilds it from disk.
2055
2289
  *
@@ -2153,26 +2387,32 @@ var ContentStore = class {
2153
2387
  return true;
2154
2388
  }
2155
2389
  /**
2156
- * Find the file in `dir` whose filename embeds `id`.
2157
- * Returns its root-relative path, or null if no such file (or no such dir).
2390
+ * Every file in `dir` whose filename embeds `id`, as root-relative paths
2391
+ * (empty when there is no such file, or no such dir).
2392
+ *
2393
+ * [F1] Returns ALL matches, not the first: more than one match is a
2394
+ * duplicate-ID pair, and callers must be able to tell that apart from a
2395
+ * clean single hit rather than silently picking whichever one `readdir()`
2396
+ * happened to yield first.
2158
2397
  */
2159
- async findEntryPathById(dir, id) {
2398
+ async findEntryPathsById(dir, id) {
2160
2399
  let entries;
2161
2400
  try {
2162
- entries = await fs8.readdir(dir, { withFileTypes: true });
2401
+ entries = await fs9.readdir(dir, { withFileTypes: true });
2163
2402
  } catch (err) {
2164
2403
  if (isNodeError(err) && err.code === "ENOENT")
2165
- return null;
2404
+ return [];
2166
2405
  throw err;
2167
2406
  }
2407
+ const matches = [];
2168
2408
  for (const entry of entries) {
2169
2409
  if (entry.isDirectory())
2170
2410
  continue;
2171
2411
  if (extractIdFromFilename(entry.name) === id) {
2172
- return path8.relative(this.root, path8.join(dir, entry.name));
2412
+ matches.push(path10.relative(this.root, path10.join(dir, entry.name)));
2173
2413
  }
2174
2414
  }
2175
- return null;
2415
+ return matches.sort();
2176
2416
  }
2177
2417
  /**
2178
2418
  * Get all schema items for iteration.
@@ -2181,11 +2421,11 @@ var ContentStore = class {
2181
2421
  getSchemaItems() {
2182
2422
  return this.schemaIndex.values();
2183
2423
  }
2184
- assertSchemaItem(path19) {
2185
- const normalized = normalizeFilesystemPath(path19);
2424
+ assertSchemaItem(path20) {
2425
+ const normalized = normalizeFilesystemPath(path20);
2186
2426
  const item = this.schemaIndex.get(normalized);
2187
2427
  if (!item) {
2188
- throw new ContentStoreError(`Unknown schema item: ${path19}`, "NO_SCHEMA_ITEM");
2428
+ throw new ContentStoreError(`Unknown schema item: ${path20}`, "NO_SCHEMA_ITEM");
2189
2429
  }
2190
2430
  return item;
2191
2431
  }
@@ -2268,7 +2508,7 @@ var ContentStore = class {
2268
2508
  * @param options.entryTypeName - For collections with multiple entry types, specify which one to use. Defaults to the default entry type.
2269
2509
  */
2270
2510
  async buildPaths(schemaItem, slug, options = {}) {
2271
- const rootWithSep = this.root.endsWith(path8.sep) ? this.root : `${this.root}${path8.sep}`;
2511
+ const rootWithSep = this.root.endsWith(path10.sep) ? this.root : `${this.root}${path10.sep}`;
2272
2512
  if (schemaItem.type === "entry-type") {
2273
2513
  const parentPath = schemaItem.parentPath || "";
2274
2514
  const parentCollection = this.schemaIndex.get(parentPath);
@@ -2301,7 +2541,7 @@ var ContentStore = class {
2301
2541
  const entryTypeName = entryTypeConfig?.name || "entry";
2302
2542
  let collectionRoot = await resolveCollectionPath(this.root, schemaItem.logicalPath);
2303
2543
  if (!collectionRoot) {
2304
- collectionRoot = path8.resolve(this.root, schemaItem.logicalPath);
2544
+ collectionRoot = path10.resolve(this.root, schemaItem.logicalPath);
2305
2545
  }
2306
2546
  if (!collectionRoot.startsWith(rootWithSep)) {
2307
2547
  throw new ContentStoreError("Path traversal detected", "VALIDATION");
@@ -2311,7 +2551,7 @@ var ContentStore = class {
2311
2551
  let existingEntryType;
2312
2552
  let foundExisting = false;
2313
2553
  if (!id) {
2314
- const entries = await fs8.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
2554
+ const entries = await fs9.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
2315
2555
  const existingFile = entries.find((entry) => {
2316
2556
  if (entry.isDirectory())
2317
2557
  return false;
@@ -2337,14 +2577,14 @@ var ContentStore = class {
2337
2577
  }
2338
2578
  filename = `${finalEntryTypeName}.${safeSlug}.${id}${ext}`;
2339
2579
  }
2340
- const resolved = path8.resolve(collectionRoot, filename);
2341
- const collectionRootWithSep = collectionRoot.endsWith(path8.sep) ? collectionRoot : `${collectionRoot}${path8.sep}`;
2580
+ const resolved = path10.resolve(collectionRoot, filename);
2581
+ const collectionRootWithSep = collectionRoot.endsWith(path10.sep) ? collectionRoot : `${collectionRoot}${path10.sep}`;
2342
2582
  if (!resolved.startsWith(collectionRootWithSep)) {
2343
2583
  throw new ContentStoreError("Path traversal detected", "VALIDATION");
2344
2584
  }
2345
2585
  return {
2346
2586
  absolutePath: resolved,
2347
- relativePath: path8.relative(this.root, resolved),
2587
+ relativePath: path10.relative(this.root, resolved),
2348
2588
  id,
2349
2589
  entryTypeName: finalEntryTypeName,
2350
2590
  existed
@@ -2380,8 +2620,8 @@ var ContentStore = class {
2380
2620
  async read(collectionPath, slug = "", options = {}) {
2381
2621
  const schemaItem = this.assertSchemaItem(collectionPath);
2382
2622
  const { absolutePath, relativePath, entryTypeName: resolvedEntryTypeName } = await this.buildPaths(schemaItem, slug);
2383
- const stat = await fs8.stat(absolutePath);
2384
- const raw = await fs8.readFile(absolutePath, "utf8");
2623
+ const stat = await fs9.stat(absolutePath);
2624
+ const raw = await fs9.readFile(absolutePath, "utf8");
2385
2625
  let doc;
2386
2626
  let format;
2387
2627
  let fields;
@@ -2464,103 +2704,128 @@ var ContentStore = class {
2464
2704
  }
2465
2705
  const prePass = await this.buildPaths(schemaItem, slug, { entryTypeName, existingId });
2466
2706
  let lockKey = this.entryLockKey(schemaItem, slug, prePass);
2467
- const RETRY_KEY = Symbol("retry-with-new-lock-key");
2468
- for (let attempt = 0; attempt < 10; attempt++) {
2469
- const outcome = await withLock(lockKey, async () => {
2470
- const inLock = await this.buildPaths(schemaItem, slug, {
2471
- entryTypeName,
2472
- existingId
2473
- });
2474
- const currentKey = this.entryLockKey(schemaItem, slug, inLock);
2475
- if (currentKey !== lockKey) {
2476
- lockKey = currentKey;
2477
- return RETRY_KEY;
2478
- }
2479
- const { absolutePath, relativePath, id } = inLock;
2480
- await fs8.mkdir(path8.dirname(absolutePath), { recursive: true });
2481
- if (input.expectedVersion !== void 0) {
2482
- try {
2483
- const existing = await fs8.stat(absolutePath);
2484
- if (existing.mtimeMs !== input.expectedVersion) {
2485
- throw new ContentConflictError();
2707
+ return this.withContentWriteExclusion(async () => {
2708
+ const RETRY_KEY = Symbol("retry-with-new-lock-key");
2709
+ for (let attempt = 0; attempt < 10; attempt++) {
2710
+ const outcome = await withLock(lockKey, async () => {
2711
+ const inLock = await this.buildPaths(schemaItem, slug, {
2712
+ entryTypeName,
2713
+ existingId
2714
+ });
2715
+ const currentKey = this.entryLockKey(schemaItem, slug, inLock);
2716
+ if (currentKey !== lockKey) {
2717
+ lockKey = currentKey;
2718
+ return RETRY_KEY;
2719
+ }
2720
+ const { absolutePath, relativePath, id } = inLock;
2721
+ if (id) {
2722
+ const guardIndex = this._idIndex;
2723
+ const indexedPath = guardIndex.findById(id)?.relativePath ?? null;
2724
+ if (indexedPath && indexedPath !== relativePath) {
2725
+ const quarantined = guardIndex.getDuplicateFor(id);
2726
+ const bothOnDisk = await filePathExists(absolutePath) && await filePathExists(path10.join(this.root, indexedPath));
2727
+ if (quarantined || bothOnDisk) {
2728
+ throw new DuplicateContentIdError(id, [
2729
+ indexedPath,
2730
+ relativePath,
2731
+ ...quarantined?.droppedPaths ?? [],
2732
+ ...quarantined ? [quarantined.keptPath] : []
2733
+ ]);
2734
+ }
2486
2735
  }
2487
- } catch (err) {
2488
- if (err instanceof ContentConflictError)
2489
- throw err;
2490
- if (isNodeError(err) && err.code === "ENOENT") {
2491
- } else {
2492
- throw err;
2736
+ }
2737
+ await fs9.mkdir(path10.dirname(absolutePath), { recursive: true });
2738
+ if (input.expectedVersion !== void 0) {
2739
+ try {
2740
+ const existing = await fs9.stat(absolutePath);
2741
+ if (input.expectedVersion === null) {
2742
+ throw new ContentConflictError("An entry with this slug already exists");
2743
+ }
2744
+ if (existing.mtimeMs !== input.expectedVersion) {
2745
+ throw new ContentConflictError();
2746
+ }
2747
+ } catch (err) {
2748
+ if (err instanceof ContentConflictError)
2749
+ throw err;
2750
+ if (isNodeError(err) && err.code === "ENOENT") {
2751
+ } else {
2752
+ throw err;
2753
+ }
2493
2754
  }
2494
2755
  }
2495
- }
2496
- if (existingId && !await filePathExists(absolutePath)) {
2497
- const actualRelPath = await this.findEntryPathById(path8.dirname(absolutePath), existingId);
2498
- const indexedRelPath = this._idIndex.findById(existingId)?.relativePath ?? null;
2499
- if (actualRelPath !== null && actualRelPath !== indexedRelPath) {
2500
- throw new ContentConflictError();
2756
+ if (existingId && !await filePathExists(absolutePath)) {
2757
+ const actualRelPaths = await this.findEntryPathsById(path10.dirname(absolutePath), existingId);
2758
+ if (actualRelPaths.length > 1) {
2759
+ throw new DuplicateContentIdError(existingId, actualRelPaths);
2760
+ }
2761
+ const actualRelPath = actualRelPaths[0] ?? null;
2762
+ const indexedRelPath = this._idIndex.findById(existingId)?.relativePath ?? null;
2763
+ if (actualRelPath !== null && actualRelPath !== indexedRelPath) {
2764
+ throw new ContentConflictError();
2765
+ }
2501
2766
  }
2502
- }
2503
- let content;
2504
- if (input.format === "json") {
2505
- content = `${JSON.stringify(input.data ?? {}, null, 2)}
2767
+ let content;
2768
+ if (input.format === "json") {
2769
+ content = `${JSON.stringify(input.data ?? {}, null, 2)}
2506
2770
  `;
2507
- } else if (input.format === "yaml") {
2508
- content = yamlStringify(input.data ?? {});
2509
- } else {
2510
- content = matter.stringify(input.body, input.data ?? {});
2511
- }
2512
- await atomicWriteFile(absolutePath, content);
2513
- const liveIndex = this._idIndex;
2514
- let staleOldAbsPath = null;
2515
- if (id) {
2516
- const existing = liveIndex.findById(id);
2517
- if (existing) {
2518
- if (existing.relativePath !== relativePath) {
2519
- staleOldAbsPath = path8.join(this.root, existing.relativePath);
2520
- liveIndex.updatePath(existing.id, relativePath);
2521
- }
2771
+ } else if (input.format === "yaml") {
2772
+ content = yamlStringify(input.data ?? {});
2522
2773
  } else {
2523
- liveIndex.add({
2524
- type: "entry",
2525
- relativePath,
2526
- collection: collectionPath,
2527
- slug: slug || void 0
2774
+ content = matter.stringify(input.body, input.data ?? {});
2775
+ }
2776
+ await atomicWriteFile(absolutePath, content);
2777
+ const liveIndex = this._idIndex;
2778
+ let staleOldAbsPath = null;
2779
+ if (id) {
2780
+ const existing = liveIndex.findById(id);
2781
+ if (existing) {
2782
+ if (existing.relativePath !== relativePath) {
2783
+ staleOldAbsPath = path10.join(this.root, existing.relativePath);
2784
+ liveIndex.updatePath(existing.id, relativePath);
2785
+ }
2786
+ } else {
2787
+ liveIndex.add({
2788
+ type: "entry",
2789
+ relativePath,
2790
+ collection: collectionPath,
2791
+ slug: slug || void 0
2792
+ });
2793
+ }
2794
+ }
2795
+ if (staleOldAbsPath) {
2796
+ await fs9.unlink(staleOldAbsPath).catch((err) => {
2797
+ if (!isNodeError(err) || err.code !== "ENOENT")
2798
+ throw err;
2528
2799
  });
2529
2800
  }
2530
- }
2531
- if (staleOldAbsPath) {
2532
- await fs8.unlink(staleOldAbsPath).catch((err) => {
2533
- if (!isNodeError(err) || err.code !== "ENOENT")
2534
- throw err;
2535
- });
2536
- }
2537
- await this.recordOwnMutation(liveIndex);
2538
- const afterStat = await fs8.stat(absolutePath);
2539
- const base = {
2540
- collection: schemaItem.logicalPath,
2541
- collectionName: schemaItem.name,
2542
- relativePath,
2543
- absolutePath,
2544
- version: afterStat.mtimeMs
2545
- };
2546
- if (input.format === "json") {
2547
- return { ...base, format: "json", data: input.data ?? {} };
2548
- }
2549
- if (input.format === "yaml") {
2550
- return { ...base, format: "yaml", data: input.data ?? {} };
2551
- }
2552
- return {
2553
- ...base,
2554
- format: input.format,
2555
- data: input.data ?? {},
2556
- body: input.body,
2557
- bodyFieldName: findBodyFieldName(fields)
2558
- };
2559
- });
2560
- if (typeof outcome !== "symbol")
2561
- return outcome;
2562
- }
2563
- throw new ContentConflictError();
2801
+ await this.recordOwnMutation(liveIndex);
2802
+ const afterStat = await fs9.stat(absolutePath);
2803
+ const base = {
2804
+ collection: schemaItem.logicalPath,
2805
+ collectionName: schemaItem.name,
2806
+ relativePath,
2807
+ absolutePath,
2808
+ version: afterStat.mtimeMs
2809
+ };
2810
+ if (input.format === "json") {
2811
+ return { ...base, format: "json", data: input.data ?? {} };
2812
+ }
2813
+ if (input.format === "yaml") {
2814
+ return { ...base, format: "yaml", data: input.data ?? {} };
2815
+ }
2816
+ return {
2817
+ ...base,
2818
+ format: input.format,
2819
+ data: input.data ?? {},
2820
+ body: input.body,
2821
+ bodyFieldName: findBodyFieldName(fields)
2822
+ };
2823
+ });
2824
+ if (typeof outcome !== "symbol")
2825
+ return outcome;
2826
+ }
2827
+ throw new ContentConflictError();
2828
+ });
2564
2829
  }
2565
2830
  /**
2566
2831
  * Read an entry by its ID (UUID).
@@ -2612,7 +2877,7 @@ var ContentStore = class {
2612
2877
  const schemaItem = this.assertSchemaItem(collectionPath);
2613
2878
  const { absolutePath } = await this.buildPaths(schemaItem, slug);
2614
2879
  try {
2615
- await fs8.access(absolutePath);
2880
+ await fs9.access(absolutePath);
2616
2881
  return true;
2617
2882
  } catch (err) {
2618
2883
  if (isNodeError(err) && err.code === "ENOENT")
@@ -2651,7 +2916,7 @@ var ContentStore = class {
2651
2916
  const collectionRoot = await resolveCollectionPath(this.root, collectionPath);
2652
2917
  if (!collectionRoot)
2653
2918
  return 0;
2654
- const entries = await fs8.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
2919
+ const entries = await fs9.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
2655
2920
  return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
2656
2921
  }
2657
2922
  /**
@@ -2675,34 +2940,36 @@ var ContentStore = class {
2675
2940
  const collection = this.assertCollection(collectionPath);
2676
2941
  const prePass = await this.buildPaths(collection, slug);
2677
2942
  if (!prePass.existed) {
2678
- await fs8.unlink(prePass.absolutePath);
2943
+ await fs9.unlink(prePass.absolutePath);
2679
2944
  return;
2680
2945
  }
2681
2946
  let lockKey = this.entryLockKey(collection, slug, prePass);
2682
2947
  await this.afterPrePassForTesting();
2683
- const RETRY_KEY = Symbol("retry-with-new-lock-key");
2684
- for (let attempt = 0; attempt < 10; attempt++) {
2685
- const outcome = await withLock(lockKey, async () => {
2686
- const inLock = await this.buildPaths(collection, slug);
2687
- const currentKey = this.entryLockKey(collection, slug, inLock);
2688
- if (currentKey !== lockKey) {
2689
- lockKey = currentKey;
2690
- return RETRY_KEY;
2691
- }
2692
- const { absolutePath, relativePath } = inLock;
2693
- await fs8.unlink(absolutePath);
2694
- const liveIndex = this._idIndex;
2695
- const id = liveIndex.findByPath(relativePath);
2696
- if (id) {
2697
- liveIndex.remove(id);
2698
- }
2699
- await this.recordOwnMutation(liveIndex);
2700
- return void 0;
2701
- });
2702
- if (outcome !== RETRY_KEY)
2703
- return;
2704
- }
2705
- throw new ContentConflictError();
2948
+ return this.withContentWriteExclusion(async () => {
2949
+ const RETRY_KEY = Symbol("retry-with-new-lock-key");
2950
+ for (let attempt = 0; attempt < 10; attempt++) {
2951
+ const outcome = await withLock(lockKey, async () => {
2952
+ const inLock = await this.buildPaths(collection, slug);
2953
+ const currentKey = this.entryLockKey(collection, slug, inLock);
2954
+ if (currentKey !== lockKey) {
2955
+ lockKey = currentKey;
2956
+ return RETRY_KEY;
2957
+ }
2958
+ const { absolutePath, relativePath } = inLock;
2959
+ await fs9.unlink(absolutePath);
2960
+ const liveIndex = this._idIndex;
2961
+ const id = liveIndex.findByPath(relativePath);
2962
+ if (id) {
2963
+ liveIndex.remove(id);
2964
+ }
2965
+ await this.recordOwnMutation(liveIndex);
2966
+ return void 0;
2967
+ });
2968
+ if (outcome !== RETRY_KEY)
2969
+ return;
2970
+ }
2971
+ throw new ContentConflictError();
2972
+ });
2706
2973
  }
2707
2974
  /**
2708
2975
  * Rename an entry by changing its slug (middle segment of filename).
@@ -2733,70 +3000,72 @@ var ContentStore = class {
2733
3000
  let sourceId = prePass.id;
2734
3001
  let destLockKey = this.createLockKey(collection, safeNewSlug);
2735
3002
  await this.afterPrePassForTesting();
2736
- const RETRY_KEY = Symbol("retry-with-new-lock-key");
2737
- for (let attempt = 0; attempt < 10; attempt++) {
2738
- const outcome = await withLocks([sourceLockKey, destLockKey], async () => {
2739
- const inLock = await this.buildPaths(collection, currentSlug);
2740
- if (!inLock.existed) {
2741
- throw new ContentStoreError(`Entry not found: ${currentSlug}`, "NOT_FOUND");
2742
- }
2743
- const currentSourceKey = this.entryLockKey(collection, currentSlug, inLock);
2744
- const currentDestKey = this.createLockKey(collection, safeNewSlug);
2745
- if (currentSourceKey !== sourceLockKey || inLock.id !== sourceId) {
2746
- sourceLockKey = currentSourceKey;
3003
+ return this.withContentWriteExclusion(async () => {
3004
+ const RETRY_KEY = Symbol("retry-with-new-lock-key");
3005
+ for (let attempt = 0; attempt < 10; attempt++) {
3006
+ const outcome = await withLocks([sourceLockKey, destLockKey], async () => {
3007
+ const inLock = await this.buildPaths(collection, currentSlug);
3008
+ if (!inLock.existed) {
3009
+ throw new ContentStoreError(`Entry not found: ${currentSlug}`, "NOT_FOUND");
3010
+ }
3011
+ const currentSourceKey = this.entryLockKey(collection, currentSlug, inLock);
3012
+ const currentDestKey = this.createLockKey(collection, safeNewSlug);
3013
+ if (currentSourceKey !== sourceLockKey || inLock.id !== sourceId) {
3014
+ sourceLockKey = currentSourceKey;
3015
+ destLockKey = currentDestKey;
3016
+ sourceId = inLock.id;
3017
+ return RETRY_KEY;
3018
+ }
2747
3019
  destLockKey = currentDestKey;
2748
- sourceId = inLock.id;
2749
- return RETRY_KEY;
2750
- }
2751
- destLockKey = currentDestKey;
2752
- const { absolutePath: currentPath, relativePath: currentRelPath } = inLock;
2753
- const currentFilename = path8.basename(currentPath);
2754
- const parts = currentFilename.split(".");
2755
- if (parts.length < 4) {
2756
- throw new ContentStoreError(`Invalid entry filename format: ${currentFilename}`, "VALIDATION");
2757
- }
2758
- const entryTypeName = parts[0];
2759
- const contentId = parts[parts.length - 2];
2760
- const ext = `.${parts[parts.length - 1]}`;
2761
- const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
2762
- const parentDir = path8.dirname(currentPath);
2763
- const newPath = path8.join(parentDir, newFilename);
2764
- try {
2765
- const entries = await fs8.readdir(parentDir, { withFileTypes: true });
2766
- for (const entry of entries) {
2767
- if (entry.isDirectory())
2768
- continue;
2769
- const existingSlug = extractSlugFromFilename(entry.name, entryTypeName);
2770
- if (existingSlug === safeNewSlug) {
2771
- throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
3020
+ const { absolutePath: currentPath, relativePath: currentRelPath } = inLock;
3021
+ const currentFilename = path10.basename(currentPath);
3022
+ const parts = currentFilename.split(".");
3023
+ if (parts.length < 4) {
3024
+ throw new ContentStoreError(`Invalid entry filename format: ${currentFilename}`, "VALIDATION");
3025
+ }
3026
+ const entryTypeName = parts[0];
3027
+ const contentId = parts[parts.length - 2];
3028
+ const ext = `.${parts[parts.length - 1]}`;
3029
+ const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
3030
+ const parentDir = path10.dirname(currentPath);
3031
+ const newPath = path10.join(parentDir, newFilename);
3032
+ try {
3033
+ const entries = await fs9.readdir(parentDir, { withFileTypes: true });
3034
+ for (const entry of entries) {
3035
+ if (entry.isDirectory())
3036
+ continue;
3037
+ const existingSlug = extractSlugFromFilename(entry.name, entryTypeName);
3038
+ if (existingSlug === safeNewSlug) {
3039
+ throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
3040
+ }
2772
3041
  }
3042
+ } catch (err) {
3043
+ if (err instanceof ContentStoreError)
3044
+ throw err;
2773
3045
  }
2774
- } catch (err) {
2775
- if (err instanceof ContentStoreError)
3046
+ try {
3047
+ await fs9.link(currentPath, newPath);
3048
+ } catch (err) {
3049
+ if (isNodeError(err) && err.code === "EEXIST") {
3050
+ throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
3051
+ }
2776
3052
  throw err;
2777
- }
2778
- try {
2779
- await fs8.link(currentPath, newPath);
2780
- } catch (err) {
2781
- if (isNodeError(err) && err.code === "EEXIST") {
2782
- throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
2783
3053
  }
2784
- throw err;
2785
- }
2786
- await fs8.unlink(currentPath);
2787
- const newRelativePath = path8.relative(this.root, newPath);
2788
- const liveIndex = this._idIndex;
2789
- const entryId = liveIndex.findByPath(currentRelPath);
2790
- if (entryId) {
2791
- liveIndex.updatePath(entryId, newRelativePath);
2792
- }
2793
- await this.recordOwnMutation(liveIndex);
2794
- return { newPath: `${collectionPath}/${safeNewSlug}` };
2795
- });
2796
- if (typeof outcome !== "symbol")
2797
- return outcome;
2798
- }
2799
- throw new ContentConflictError();
3054
+ await fs9.unlink(currentPath);
3055
+ const newRelativePath = path10.relative(this.root, newPath);
3056
+ const liveIndex = this._idIndex;
3057
+ const entryId = liveIndex.findByPath(currentRelPath);
3058
+ if (entryId) {
3059
+ liveIndex.updatePath(entryId, newRelativePath);
3060
+ }
3061
+ await this.recordOwnMutation(liveIndex);
3062
+ return { newPath: `${collectionPath}/${safeNewSlug}` };
3063
+ });
3064
+ if (typeof outcome !== "symbol")
3065
+ return outcome;
3066
+ }
3067
+ throw new ContentConflictError();
3068
+ });
2800
3069
  }
2801
3070
  /**
2802
3071
  * List all entries in a collection tree (including subcollections).
@@ -2953,8 +3222,8 @@ var ContentStore = class {
2953
3222
  };
2954
3223
 
2955
3224
  // dist/ai/generate.js
2956
- import path9 from "node:path";
2957
- import fs9 from "node:fs/promises";
3225
+ import path11 from "node:path";
3226
+ import fs10 from "node:fs/promises";
2958
3227
  import { minimatch } from "minimatch";
2959
3228
 
2960
3229
  // dist/ai/strip-mdx.js
@@ -3149,7 +3418,7 @@ function entryToMarkdown(entry, config) {
3149
3418
  }
3150
3419
  function renderMarkdownEntry(entry, config, skipFields) {
3151
3420
  const parts = [];
3152
- const bodyFieldTypes = /* @__PURE__ */ new Set(["rich-text", "markdown", "mdx"]);
3421
+ const bodyFieldTypes = /* @__PURE__ */ new Set(["markdown", "mdx"]);
3153
3422
  const metadataFields = flattenGroupFields(entry.fields).filter((f) => !bodyFieldTypes.has(f.type) && !skipFields.has(f.name));
3154
3423
  for (const field of metadataFields) {
3155
3424
  const value = entry.data[field.name];
@@ -3221,7 +3490,6 @@ ${String(value)}`;
3221
3490
  return `${heading} ${label}${descriptionLine}
3222
3491
 
3223
3492
  ${value ? "Yes" : "No"}`;
3224
- case "rich-text":
3225
3493
  case "markdown":
3226
3494
  case "mdx":
3227
3495
  return `${heading} ${label}${descriptionLine}
@@ -3492,8 +3760,8 @@ function computeEntryUrl(collection, slug, contentRoot) {
3492
3760
  if (slug && slug !== "index") {
3493
3761
  segments.push(slug);
3494
3762
  }
3495
- const path19 = segments.length > 0 ? `/${segments.join("/")}` : "/";
3496
- return path19.toLowerCase();
3763
+ const path20 = segments.length > 0 ? `/${segments.join("/")}` : "/";
3764
+ return path20.toLowerCase();
3497
3765
  }
3498
3766
 
3499
3767
  // dist/entry-link-resolver.js
@@ -3668,7 +3936,7 @@ async function processCollection(store, collection, flatSchema, contentRoot, con
3668
3936
  const listed = await store.getCollectionEntryPaths(collection.logicalPath);
3669
3937
  const directEntries = listed.filter((e) => e.collection === collection.logicalPath);
3670
3938
  for (const listEntry of directEntries) {
3671
- const entryTypeName = extractEntryTypeFromFilename(path9.basename(listEntry.relativePath));
3939
+ const entryTypeName = extractEntryTypeFromFilename(path11.basename(listEntry.relativePath));
3672
3940
  if (!entryTypeName)
3673
3941
  continue;
3674
3942
  if (config?.exclude?.entryTypes?.includes(entryTypeName))
@@ -3686,7 +3954,7 @@ async function processCollection(store, collection, flatSchema, contentRoot, con
3686
3954
  }
3687
3955
  if (config?.exclude?.where?.(aiEntry))
3688
3956
  continue;
3689
- await runEntryTransform(aiEntry, doc.absolutePath, extractIdFromFilename(path9.basename(listEntry.relativePath)), config);
3957
+ await runEntryTransform(aiEntry, doc.absolutePath, extractIdFromFilename(path11.basename(listEntry.relativePath)), config);
3690
3958
  entries.push(aiEntry);
3691
3959
  const entryFilePath = `${cleanPath}/${listEntry.slug}.md`;
3692
3960
  const entryMarkdown = entryToMarkdown(aiEntry, config);
@@ -3737,7 +4005,7 @@ async function processRootEntries(store, rootCollection, contentRoot, config, id
3737
4005
  const listed = await store.getCollectionEntryPaths(rootCollection.logicalPath);
3738
4006
  const directEntries = listed.filter((e) => e.collection === rootCollection.logicalPath);
3739
4007
  for (const listEntry of directEntries) {
3740
- const entryTypeName = extractEntryTypeFromFilename(path9.basename(listEntry.relativePath));
4008
+ const entryTypeName = extractEntryTypeFromFilename(path11.basename(listEntry.relativePath));
3741
4009
  if (!entryTypeName)
3742
4010
  continue;
3743
4011
  if (config?.exclude?.entryTypes?.includes(entryTypeName))
@@ -3755,7 +4023,7 @@ async function processRootEntries(store, rootCollection, contentRoot, config, id
3755
4023
  }
3756
4024
  if (config?.exclude?.where?.(aiEntry))
3757
4025
  continue;
3758
- await runEntryTransform(aiEntry, doc.absolutePath, extractIdFromFilename(path9.basename(listEntry.relativePath)), config);
4026
+ await runEntryTransform(aiEntry, doc.absolutePath, extractIdFromFilename(path11.basename(listEntry.relativePath)), config);
3759
4027
  entries.push(aiEntry);
3760
4028
  const entryFilePath = `${listEntry.slug}.md`;
3761
4029
  const entryMarkdown = entryToMarkdown(aiEntry, config);
@@ -3803,19 +4071,19 @@ function docToAIEntry(doc, slug, entryTypeName, entryTypeConfig, cleanCollection
3803
4071
  };
3804
4072
  }
3805
4073
  function makeReadSibling(dir) {
3806
- const resolvedDir = path9.resolve(dir);
4074
+ const resolvedDir = path11.resolve(dir);
3807
4075
  return async (name) => {
3808
- if (!name || name.includes("/") || name.includes("\\") || path9.isAbsolute(name) || hasTraversalSequence(name)) {
4076
+ if (!name || name.includes("/") || name.includes("\\") || path11.isAbsolute(name) || hasTraversalSequence(name)) {
3809
4077
  return null;
3810
4078
  }
3811
- const abs = path9.resolve(resolvedDir, name);
3812
- if (path9.dirname(abs) !== resolvedDir)
4079
+ const abs = path11.resolve(resolvedDir, name);
4080
+ if (path11.dirname(abs) !== resolvedDir)
3813
4081
  return null;
3814
4082
  try {
3815
- const stats = await fs9.lstat(abs);
4083
+ const stats = await fs10.lstat(abs);
3816
4084
  if (!stats.isFile())
3817
4085
  return null;
3818
- return await fs9.readFile(abs, "utf8");
4086
+ return await fs10.readFile(abs, "utf8");
3819
4087
  } catch (err) {
3820
4088
  if (isNodeError(err) && (err.code === "ENOENT" || err.code === "ENOTDIR"))
3821
4089
  return null;
@@ -3829,7 +4097,7 @@ async function runEntryTransform(entry, absolutePath, contentId, config) {
3829
4097
  return;
3830
4098
  const ctx = {
3831
4099
  contentId: contentId ?? "",
3832
- readSibling: makeReadSibling(path9.dirname(absolutePath))
4100
+ readSibling: makeReadSibling(path11.dirname(absolutePath))
3833
4101
  };
3834
4102
  try {
3835
4103
  const appended = await transform(entry, ctx);
@@ -3866,15 +4134,15 @@ function matchesBundleFilter(entry, filter, contentRoot) {
3866
4134
  }
3867
4135
 
3868
4136
  // dist/branch-workspace.js
3869
- import path15 from "node:path";
4137
+ import path16 from "node:path";
3870
4138
 
3871
4139
  // dist/branch-metadata.js
3872
- import fs12 from "node:fs/promises";
3873
- import path12 from "node:path";
4140
+ import fs13 from "node:fs/promises";
4141
+ import path14 from "node:path";
3874
4142
 
3875
4143
  // dist/branch-registry.js
3876
- import fs10 from "node:fs/promises";
3877
- import path10 from "node:path";
4144
+ import fs11 from "node:fs/promises";
4145
+ import path12 from "node:path";
3878
4146
  init_logger();
3879
4147
  var log3 = createDebugLogger({ prefix: "BranchRegistry" });
3880
4148
  var REGISTRY_FILE = "branches.json";
@@ -3887,10 +4155,10 @@ var BranchRegistry = class {
3887
4155
  constructor(root) {
3888
4156
  this.regenInFlight = null;
3889
4157
  this.lastForcedRefreshMs = 0;
3890
- this.root = path10.resolve(root);
3891
- this.registryPath = path10.join(this.root, REGISTRY_FILE);
3892
- this.stalePath = path10.join(this.root, REGISTRY_STALE_FILE);
3893
- this.tempPath = path10.join(this.root, REGISTRY_TEMP_FILE);
4158
+ this.root = path12.resolve(root);
4159
+ this.registryPath = path12.join(this.root, REGISTRY_FILE);
4160
+ this.stalePath = path12.join(this.root, REGISTRY_STALE_FILE);
4161
+ this.tempPath = path12.join(this.root, REGISTRY_TEMP_FILE);
3894
4162
  }
3895
4163
  /**
3896
4164
  * Returns all branches. Uses the cached snapshot if its embedded generation
@@ -3899,7 +4167,7 @@ var BranchRegistry = class {
3899
4167
  async list() {
3900
4168
  let parsed;
3901
4169
  try {
3902
- const raw = await fs10.readFile(this.registryPath, "utf8");
4170
+ const raw = await fs11.readFile(this.registryPath, "utf8");
3903
4171
  parsed = JSON.parse(raw);
3904
4172
  } catch (err) {
3905
4173
  if (isNotFoundError(err)) {
@@ -3988,7 +4256,7 @@ var BranchRegistry = class {
3988
4256
  async performRegenerate() {
3989
4257
  const read = await readResourceGeneration(this.root, RESOURCE2);
3990
4258
  const branches = await this.scanBranchDirectories();
3991
- await fs10.unlink(this.stalePath).catch(() => {
4259
+ await fs11.unlink(this.stalePath).catch(() => {
3992
4260
  });
3993
4261
  if (!read.ok) {
3994
4262
  return branches;
@@ -3999,12 +4267,12 @@ var BranchRegistry = class {
3999
4267
  generation: read.token
4000
4268
  };
4001
4269
  const uniqueTempPath = `${this.tempPath}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
4002
- await fs10.mkdir(this.root, { recursive: true });
4003
- await fs10.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
4270
+ await fs11.mkdir(this.root, { recursive: true });
4271
+ await fs11.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
4004
4272
  try {
4005
- await fs10.rename(uniqueTempPath, this.registryPath);
4273
+ await fs11.rename(uniqueTempPath, this.registryPath);
4006
4274
  } catch (err) {
4007
- await fs10.unlink(uniqueTempPath).catch(() => {
4275
+ await fs11.unlink(uniqueTempPath).catch(() => {
4008
4276
  });
4009
4277
  throw err;
4010
4278
  }
@@ -4018,12 +4286,12 @@ var BranchRegistry = class {
4018
4286
  async scanBranchDirectories() {
4019
4287
  const branches = [];
4020
4288
  try {
4021
- const entries = await fs10.readdir(this.root, { withFileTypes: true });
4289
+ const entries = await fs11.readdir(this.root, { withFileTypes: true });
4022
4290
  for (const entry of entries) {
4023
4291
  if (!entry.isDirectory() || entry.name.startsWith(".")) {
4024
4292
  continue;
4025
4293
  }
4026
- const branchRoot = path10.join(this.root, entry.name);
4294
+ const branchRoot = path12.join(this.root, entry.name);
4027
4295
  let meta;
4028
4296
  try {
4029
4297
  meta = await BranchMetadataFileManager.loadOnly(branchRoot);
@@ -4050,10 +4318,10 @@ var BranchRegistry = class {
4050
4318
  };
4051
4319
 
4052
4320
  // dist/utils/occ-json-write.js
4053
- import fs11 from "node:fs/promises";
4054
- import path11 from "node:path";
4321
+ import fs12 from "node:fs/promises";
4322
+ import path13 from "node:path";
4055
4323
  import { randomUUID as randomUUID2 } from "node:crypto";
4056
- import lockfile from "proper-lockfile";
4324
+ import lockfile2 from "proper-lockfile";
4057
4325
  var log4 = createDebugLogger({ prefix: "OccJsonWrite" });
4058
4326
  var OccWriteConflictError = class extends Error {
4059
4327
  constructor(message = "Concurrent modification detected") {
@@ -4067,29 +4335,29 @@ async function writeOccJsonFile(filePath, payload, options) {
4067
4335
  const writeId = randomUUID2();
4068
4336
  const data = { ...payload, version: newVersion, writeId };
4069
4337
  const content = JSON.stringify(data, null, 2) + (trailingNewline ? "\n" : "");
4070
- const dir = path11.dirname(filePath);
4071
- await fs11.mkdir(dir, { recursive: true });
4338
+ const dir = path13.dirname(filePath);
4339
+ await fs12.mkdir(dir, { recursive: true });
4072
4340
  const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
4073
- await fs11.writeFile(tempPath, content, "utf-8");
4341
+ await fs12.writeFile(tempPath, content, "utf-8");
4074
4342
  if (expectedVersion === null) {
4075
4343
  try {
4076
- await fs11.link(tempPath, filePath);
4344
+ await fs12.link(tempPath, filePath);
4077
4345
  } catch (err) {
4078
- await fs11.unlink(tempPath).catch(() => {
4346
+ await fs12.unlink(tempPath).catch(() => {
4079
4347
  });
4080
4348
  if (isFileExistsError(err)) {
4081
4349
  throw new OccWriteConflictError();
4082
4350
  }
4083
4351
  throw err;
4084
4352
  }
4085
- await fs11.unlink(tempPath).catch(() => {
4353
+ await fs12.unlink(tempPath).catch(() => {
4086
4354
  });
4087
4355
  return { version: newVersion, writeId };
4088
4356
  }
4089
4357
  try {
4090
4358
  let currentVersion = null;
4091
4359
  try {
4092
- const current = JSON.parse(await fs11.readFile(filePath, "utf-8"));
4360
+ const current = JSON.parse(await fs12.readFile(filePath, "utf-8"));
4093
4361
  currentVersion = current.version ?? 0;
4094
4362
  } catch {
4095
4363
  currentVersion = null;
@@ -4097,16 +4365,16 @@ async function writeOccJsonFile(filePath, payload, options) {
4097
4365
  if (currentVersion !== expectedVersion) {
4098
4366
  throw new OccWriteConflictError();
4099
4367
  }
4100
- await fs11.rename(tempPath, filePath);
4368
+ await fs12.rename(tempPath, filePath);
4101
4369
  if (settleMs > 0) {
4102
4370
  await new Promise((resolve2) => setTimeout(resolve2, settleMs));
4103
4371
  }
4104
- const afterWrite = JSON.parse(await fs11.readFile(filePath, "utf-8"));
4372
+ const afterWrite = JSON.parse(await fs12.readFile(filePath, "utf-8"));
4105
4373
  if (afterWrite.writeId !== writeId) {
4106
4374
  throw new OccWriteConflictError();
4107
4375
  }
4108
4376
  } catch (err) {
4109
- await fs11.unlink(tempPath).catch(() => {
4377
+ await fs12.unlink(tempPath).catch(() => {
4110
4378
  });
4111
4379
  throw err;
4112
4380
  }
@@ -4132,13 +4400,13 @@ async function withOccRetry(operation, options) {
4132
4400
  throw new Error("Unreachable");
4133
4401
  }
4134
4402
  async function withOccFileLock(filePath, fn) {
4135
- const dir = path11.dirname(filePath);
4136
- await fs11.mkdir(dir, { recursive: true });
4403
+ const dir = path13.dirname(filePath);
4404
+ await fs12.mkdir(dir, { recursive: true });
4137
4405
  const MAX_ATTEMPTS = 14;
4138
4406
  let release = null;
4139
4407
  for (let attempt = 1; release === null; attempt++) {
4140
4408
  try {
4141
- release = await lockfile.lock(filePath, {
4409
+ release = await lockfile2.lock(filePath, {
4142
4410
  lockfilePath: `${filePath}.lock`,
4143
4411
  realpath: false,
4144
4412
  stale: 1e4,
@@ -4196,8 +4464,8 @@ var BranchMetadataCorruptError = class extends Error {
4196
4464
  };
4197
4465
  var BranchMetadataFileManager = class _BranchMetadataFileManager {
4198
4466
  constructor(branchRoot, baseRoot, options) {
4199
- this.branchRoot = path12.resolve(branchRoot);
4200
- this.filePath = path12.join(this.branchRoot, BRANCH_META_DIR, BRANCH_META_FILE);
4467
+ this.branchRoot = path14.resolve(branchRoot);
4468
+ this.filePath = path14.join(this.branchRoot, BRANCH_META_DIR, BRANCH_META_FILE);
4201
4469
  this.baseRoot = baseRoot;
4202
4470
  this.settleMs = options?.settleMs;
4203
4471
  }
@@ -4206,10 +4474,10 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
4206
4474
  * Use this for read-only access (e.g., in registry scanning or loadBranchContext).
4207
4475
  */
4208
4476
  static async loadOnly(branchRoot) {
4209
- const resolvedRoot = path12.resolve(branchRoot);
4210
- const filePath = path12.join(resolvedRoot, BRANCH_META_DIR, BRANCH_META_FILE);
4477
+ const resolvedRoot = path14.resolve(branchRoot);
4478
+ const filePath = path14.join(resolvedRoot, BRANCH_META_DIR, BRANCH_META_FILE);
4211
4479
  try {
4212
- const raw = await fs12.readFile(filePath, "utf8");
4480
+ const raw = await fs13.readFile(filePath, "utf8");
4213
4481
  return JSON.parse(raw);
4214
4482
  } catch (err) {
4215
4483
  if (isNotFoundError(err)) {
@@ -4230,7 +4498,7 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
4230
4498
  }
4231
4499
  async load() {
4232
4500
  try {
4233
- const raw = await fs12.readFile(this.filePath, "utf8");
4501
+ const raw = await fs13.readFile(this.filePath, "utf8");
4234
4502
  const parsed = JSON.parse(raw);
4235
4503
  const version = parsed.version ?? 0;
4236
4504
  return { meta: parsed, version };
@@ -4292,7 +4560,7 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
4292
4560
  */
4293
4561
  async save(incoming) {
4294
4562
  try {
4295
- await fs12.stat(this.branchRoot);
4563
+ await fs13.stat(this.branchRoot);
4296
4564
  } catch (err) {
4297
4565
  if (isNotFoundError(err)) {
4298
4566
  throw new BranchMetadataConflictError("Branch no longer exists");
@@ -4381,7 +4649,7 @@ init_operating_mode();
4381
4649
 
4382
4650
  // dist/git-manager.js
4383
4651
  import fs14 from "node:fs/promises";
4384
- import path14 from "node:path";
4652
+ import path15 from "node:path";
4385
4653
  import { simpleGit as simpleGit2 } from "simple-git";
4386
4654
 
4387
4655
  // dist/utils/git.js
@@ -4404,6 +4672,10 @@ function isNetworkRemoteUrl(url) {
4404
4672
  return false;
4405
4673
  return COLON_BEFORE_SLASH_PATTERN.test(url);
4406
4674
  }
4675
+ var MISSING_REMOTE_REF_REASONS = ["couldn't find remote ref", "Couldn't find remote ref"];
4676
+ function isMissingRemoteRefFailure(message) {
4677
+ return MISSING_REMOTE_REF_REASONS.some((reason) => message.includes(reason));
4678
+ }
4407
4679
  async function detectHeadBranch(repoRoot, fallback = "main") {
4408
4680
  try {
4409
4681
  const git = simpleGit({ baseDir: repoRoot });
@@ -4422,19 +4694,6 @@ async function resolveBaseBranch(options) {
4422
4694
  return "main";
4423
4695
  }
4424
4696
 
4425
- // dist/utils/provisioning-lock.js
4426
- import fs13 from "node:fs/promises";
4427
- import path13 from "node:path";
4428
- import lockfile2 from "proper-lockfile";
4429
- async function acquireProvisioningLock(lockTargetDir, lockName) {
4430
- await fs13.mkdir(lockTargetDir, { recursive: true });
4431
- return lockfile2.lock(lockTargetDir, {
4432
- lockfilePath: path13.join(lockTargetDir, lockName),
4433
- retries: { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true },
4434
- stale: 3e4
4435
- });
4436
- }
4437
-
4438
4697
  // dist/git-manager.js
4439
4698
  var log5 = createDebugLogger({ prefix: "GitManager" });
4440
4699
  var GIT_ENV_PASSTHROUGH = /^(PATH|HOME|USER|LANG|LC_[A-Z]+|TZ|TMPDIR|GIT_(AUTHOR|COMMITTER)_(NAME|EMAIL|DATE)|GIT_TERMINAL_PROMPT|GIT_TRACE[0-9A-Z_]*)$/;
@@ -4450,8 +4709,8 @@ function gitChildEnv(overrides) {
4450
4709
  var remoteInitLocks = /* @__PURE__ */ new Map();
4451
4710
  var GITHUB_TRACKING_REF_PREFIX = "refs/remotes/github/";
4452
4711
  async function ensureGitExcludePattern(repoPath, pattern) {
4453
- const excludePath = path14.join(repoPath, ".git", "info", "exclude");
4454
- await fs14.mkdir(path14.dirname(excludePath), { recursive: true });
4712
+ const excludePath = path15.join(repoPath, ".git", "info", "exclude");
4713
+ await fs14.mkdir(path15.dirname(excludePath), { recursive: true });
4455
4714
  let content = "";
4456
4715
  try {
4457
4716
  content = await fs14.readFile(excludePath, "utf-8");
@@ -4476,14 +4735,23 @@ var GitConflictError = class extends Error {
4476
4735
  this.name = "GitConflictError";
4477
4736
  }
4478
4737
  };
4738
+ var GitRemoteRefMissingError = class extends Error {
4739
+ constructor(branch, remote, gitError) {
4740
+ super(`Remote '${remote}' has no ref for branch '${branch}' yet`);
4741
+ this.branch = branch;
4742
+ this.remote = remote;
4743
+ this.gitError = gitError;
4744
+ this.name = "GitRemoteRefMissingError";
4745
+ }
4746
+ };
4479
4747
  var GitManager = class _GitManager {
4480
4748
  constructor(options, gitOptions) {
4481
- this.repoPath = path14.resolve(options.repoPath);
4749
+ this.repoPath = path15.resolve(options.repoPath);
4482
4750
  this.baseBranch = options.baseBranch ?? "main";
4483
4751
  this.remote = options.remote ?? "origin";
4484
4752
  this.skipIndexMarker = options.skipIndexMarker ?? false;
4485
4753
  this.git = simpleGit2({ baseDir: this.repoPath, ...gitOptions });
4486
- this.git.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path14.dirname(this.repoPath) }));
4754
+ this.git.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path15.dirname(this.repoPath) }));
4487
4755
  }
4488
4756
  static async cloneRepo(remoteUrl, targetPath, baseBranch = "main") {
4489
4757
  log5.debug("git", "Cloning repository", {
@@ -4525,7 +4793,7 @@ var GitManager = class _GitManager {
4525
4793
  remotePath: options.remotePath,
4526
4794
  baseBranch: options.baseBranch
4527
4795
  });
4528
- const remoteParent = path14.dirname(options.remotePath);
4796
+ const remoteParent = path15.dirname(options.remotePath);
4529
4797
  releaseLock = await acquireProvisioningLock(remoteParent, ".remote-init.lock");
4530
4798
  let remoteExists = false;
4531
4799
  try {
@@ -4631,8 +4899,8 @@ var GitManager = class _GitManager {
4631
4899
  * --list` instead of `rev-parse --verify --quiet`: simple-git only fails a
4632
4900
  * task on stderr output, and `--quiet` suppresses exactly that).
4633
4901
  * `--end-of-options` guards the ref-name positionals the same way
4634
- * `push`/`forcePush` below do, since `branch` here can be a sanitized but
4635
- * otherwise caller-influenced string.
4902
+ * `push` below does, since `branch` here can be a sanitized but otherwise
4903
+ * caller-influenced string.
4636
4904
  *
4637
4905
  * A failure here therefore means the remote itself is unreadable and is
4638
4906
  * surfaced, NOT treated as "branch absent" — that would route
@@ -4781,7 +5049,7 @@ var GitManager = class _GitManager {
4781
5049
  */
4782
5050
  static async validateGitRepoExists(repoPath) {
4783
5051
  try {
4784
- const stat = await fs14.stat(path14.join(repoPath, ".git"));
5052
+ const stat = await fs14.stat(path15.join(repoPath, ".git"));
4785
5053
  if (!stat.isDirectory()) {
4786
5054
  throw new Error(`Expected git repo at ${repoPath}`);
4787
5055
  }
@@ -4872,8 +5140,8 @@ var GitManager = class _GitManager {
4872
5140
  if (config.shouldAutoInitLocal) {
4873
5141
  const gitRoot = await this.findGitRoot();
4874
5142
  const sourceRoot = options.sourceRoot;
4875
- const sourcePath = sourceRoot ? path14.resolve(gitRoot, sourceRoot) : gitRoot;
4876
- const localRemotePath = path14.join(sourcePath, config.defaultRemotePath);
5143
+ const sourcePath = sourceRoot ? path15.resolve(gitRoot, sourceRoot) : gitRoot;
5144
+ const localRemotePath = path15.join(sourcePath, config.defaultRemotePath);
4877
5145
  await this.ensureLocalSimulatedRemote({
4878
5146
  remotePath: localRemotePath,
4879
5147
  sourcePath: gitRoot,
@@ -4901,7 +5169,7 @@ var GitManager = class _GitManager {
4901
5169
  static async repoExistsAt(workspacePath) {
4902
5170
  try {
4903
5171
  const checkGit = simpleGit2({ baseDir: workspacePath });
4904
- checkGit.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path14.dirname(workspacePath) }));
5172
+ checkGit.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path15.dirname(workspacePath) }));
4905
5173
  await checkGit.raw(["rev-parse", "--git-dir"]);
4906
5174
  return true;
4907
5175
  } catch {
@@ -4923,12 +5191,12 @@ var GitManager = class _GitManager {
4923
5191
  const baseBranch = await resolveBaseBranch({
4924
5192
  defaultBaseBranch: options.baseBranch,
4925
5193
  mode: options.mode,
4926
- detectFrom: options.sourceRoot ? path14.resolve(process.cwd(), options.sourceRoot) : process.cwd()
5194
+ detectFrom: options.sourceRoot ? path15.resolve(process.cwd(), options.sourceRoot) : process.cwd()
4927
5195
  });
4928
5196
  const remoteName = options.remoteName ?? "origin";
4929
5197
  const repoExists = await _GitManager.repoExistsAt(options.workspacePath);
4930
5198
  if (!repoExists) {
4931
- const gitPath = path14.join(options.workspacePath, ".git");
5199
+ const gitPath = path15.join(options.workspacePath, ".git");
4932
5200
  try {
4933
5201
  const stat = await fs14.stat(gitPath);
4934
5202
  if (stat.isDirectory()) {
@@ -4962,7 +5230,7 @@ var GitManager = class _GitManager {
4962
5230
  }
4963
5231
  justCloned = true;
4964
5232
  const freshGit = simpleGit2({ baseDir: options.workspacePath });
4965
- freshGit.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path14.dirname(options.workspacePath) }));
5233
+ freshGit.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path15.dirname(options.workspacePath) }));
4966
5234
  await freshGit.addConfig("canopycms.managed", "true");
4967
5235
  await freshGit.addConfig("user.name", options.gitBotAuthorName);
4968
5236
  await freshGit.addConfig("user.email", options.gitBotAuthorEmail);
@@ -5105,9 +5373,16 @@ var GitManager = class _GitManager {
5105
5373
  async pullCurrentBranchInner() {
5106
5374
  const branches = await this.git.branch();
5107
5375
  const currentBranch = branches.current;
5108
- await this.git.fetch(this.remote, currentBranch);
5109
5376
  try {
5110
- await this.git.merge([`${this.remote}/${currentBranch}`]);
5377
+ await this.git.fetch(this.remote, currentBranch);
5378
+ } catch (err) {
5379
+ if (!isMissingRemoteRefFailure(getErrorMessage(err)))
5380
+ throw err;
5381
+ throw new GitRemoteRefMissingError(currentBranch, this.remote, err);
5382
+ }
5383
+ const fetchedTip = (await this.git.revparse(["FETCH_HEAD"])).trim();
5384
+ try {
5385
+ await this.git.merge([fetchedTip]);
5111
5386
  } catch (err) {
5112
5387
  try {
5113
5388
  const status = await this.git.status();
@@ -5169,6 +5444,44 @@ var GitManager = class _GitManager {
5169
5444
  `${target}:${target}`
5170
5445
  ]);
5171
5446
  }
5447
+ /**
5448
+ * Check whether the local branch has commits the remote mirror doesn't
5449
+ * have -- i.e. whether push() would actually move the remote ref forward.
5450
+ *
5451
+ * Exists so callers (submitBranch) can gate pushing on "is there anything
5452
+ * new to send" rather than on "is the working tree dirty": committing
5453
+ * cleans the tree, so a dirty-tree gate around commit+push skips the push
5454
+ * entirely on a retry after a failed push, even though the just-created
5455
+ * commit never reached the remote (see services.ts submitBranch).
5456
+ *
5457
+ * Workspaces are cloned `--single-branch`, so the remote-tracking ref for
5458
+ * any branch other than the one cloned never exists locally -- same
5459
+ * constraint pullBaseInner works around. This fetches the specific branch
5460
+ * directly (bypassing the configured single-branch refspec, exactly like
5461
+ * pullBaseInner) and pins the result to FETCH_HEAD's SHA immediately after
5462
+ * the fetch that populated it, rather than trusting `<remote>/<branch>`:
5463
+ * FETCH_HEAD is a shared mutable file that any other fetch in this clone
5464
+ * can repoint before it's read.
5465
+ *
5466
+ * A branch that has never been pushed has no ref on the remote at all --
5467
+ * `git fetch` then fails ("couldn't find remote ref"), which this treats
5468
+ * as "ahead" (needs pushing) rather than an error.
5469
+ */
5470
+ async hasUnpushedCommits(branch) {
5471
+ const target = branch ?? await this.git.revparse(["--abbrev-ref", "--end-of-options", "HEAD"]);
5472
+ const localSha = (await this.git.revparse(["--end-of-options", target])).trim();
5473
+ let fetchedTip;
5474
+ try {
5475
+ await this.git.raw(["fetch", "--end-of-options", this.remote, target]);
5476
+ fetchedTip = (await this.git.revparse(["--end-of-options", "FETCH_HEAD"])).trim();
5477
+ } catch {
5478
+ return true;
5479
+ }
5480
+ if (fetchedTip === localSha)
5481
+ return false;
5482
+ const aheadCount = (await this.git.raw(["rev-list", "--count", `${fetchedTip}..${localSha}`])).trim();
5483
+ return aheadCount !== "0";
5484
+ }
5172
5485
  async ensureAuthor(author) {
5173
5486
  const config = await this.git.listConfig();
5174
5487
  const isManaged = config.all["canopycms.managed"] === "true";
@@ -5215,14 +5528,6 @@ var GitManager = class _GitManager {
5215
5528
  const status = await this.status();
5216
5529
  return status.files.map((f) => f.path);
5217
5530
  }
5218
- /**
5219
- * Force push (use with caution - for PR updates only)
5220
- * Uses --force-with-lease for safer force pushes
5221
- */
5222
- async forcePush(branch) {
5223
- const target = branch ?? await this.git.revparse(["--abbrev-ref", "HEAD"]);
5224
- await this.git.raw(["push", "--force-with-lease", "--end-of-options", this.remote, target]);
5225
- }
5226
5531
  /**
5227
5532
  * Get remote URL for current repo
5228
5533
  */
@@ -5276,8 +5581,8 @@ var GitManager = class _GitManager {
5276
5581
  } catch {
5277
5582
  }
5278
5583
  for (const [filePath, content] of Object.entries(initialFiles)) {
5279
- const absolutePath = path14.join(this.repoPath, filePath);
5280
- await fs14.mkdir(path14.dirname(absolutePath), { recursive: true });
5584
+ const absolutePath = path15.join(this.repoPath, filePath);
5585
+ await fs14.mkdir(path15.dirname(absolutePath), { recursive: true });
5281
5586
  await fs14.writeFile(absolutePath, content, "utf-8");
5282
5587
  await this.git.add(filePath);
5283
5588
  }
@@ -5307,7 +5612,7 @@ var BranchWorkspaceManager = class {
5307
5612
  branchName: options.branchName,
5308
5613
  mode: options.mode
5309
5614
  });
5310
- releaseLock = await acquireProvisioningLock(path15.dirname(options.branchRoot), `.${path15.basename(options.branchRoot)}.init.lock`);
5615
+ releaseLock = await acquireProvisioningLock(path16.dirname(options.branchRoot), `.${path16.basename(options.branchRoot)}.init.lock`);
5311
5616
  await GitManager.initializeWorkspace({
5312
5617
  workspacePath: options.branchRoot,
5313
5618
  branchName: options.branchName,
@@ -5348,7 +5653,7 @@ var BranchWorkspaceManager = class {
5348
5653
  const baseBranch = await resolveBaseBranch({
5349
5654
  defaultBaseBranch: this.config.defaultBaseBranch,
5350
5655
  mode,
5351
- detectFrom: this.config.sourceRoot ? path15.resolve(process.cwd(), this.config.sourceRoot) : void 0
5656
+ detectFrom: this.config.sourceRoot ? path16.resolve(process.cwd(), this.config.sourceRoot) : void 0
5352
5657
  });
5353
5658
  await this.ensureGitWorkspace({
5354
5659
  branchRoot,
@@ -5433,7 +5738,7 @@ async function resolveBranchRoot(config) {
5433
5738
 
5434
5739
  // dist/content-listing.js
5435
5740
  import fs15 from "node:fs/promises";
5436
- import path16 from "node:path";
5741
+ import path17 from "node:path";
5437
5742
  import matter2 from "gray-matter";
5438
5743
  import { parse as yamlParse2 } from "yaml";
5439
5744
  var log7 = createDebugLogger({ prefix: "ContentListing" });
@@ -5567,7 +5872,7 @@ var listCollectionEntries = async (root, collection) => {
5567
5872
  }
5568
5873
  const files = dirents.filter((d) => d.isFile() && validExts.some((ext) => d.name.endsWith(ext)) && d.name !== ".collection.json").sort((a, b) => a.name.localeCompare(b.name));
5569
5874
  const entries = await Promise.all(files.map(async (file) => {
5570
- const absolutePath = path16.join(collectionRoot, file.name);
5875
+ const absolutePath = path17.join(collectionRoot, file.name);
5571
5876
  const relativePath = normalizePath(root, absolutePath);
5572
5877
  const parsed = parseTypedFilename(file.name, entryTypes);
5573
5878
  if (!parsed) {
@@ -5633,7 +5938,7 @@ function resolveBlockItem(blockField, item) {
5633
5938
 
5634
5939
  // dist/validation/entry-validator.js
5635
5940
  var KNOWN_FIELD_TYPES = new Set(fieldTypes);
5636
- var STRING_FIELD_TYPES = /* @__PURE__ */ new Set(["string", "rich-text", "markdown", "mdx", "code"]);
5941
+ var STRING_FIELD_TYPES = /* @__PURE__ */ new Set(["string", "markdown", "mdx", "code"]);
5637
5942
  function isPlainRecord2(value) {
5638
5943
  return typeof value === "object" && value !== null && !Array.isArray(value);
5639
5944
  }
@@ -5649,30 +5954,30 @@ function isValidImageCropValue(value) {
5649
5954
  }
5650
5955
  return isValidCropRect(x, y, w, h);
5651
5956
  }
5652
- function validateImageValue(field, value, path19) {
5957
+ function validateImageValue(field, value, path20) {
5653
5958
  if (!isPlainRecord2(value)) {
5654
- return [{ fieldPath: path19, message: "Expected an image object with { src, alt }" }];
5959
+ return [{ fieldPath: path20, message: "Expected an image object with { src, alt }" }];
5655
5960
  }
5656
5961
  const errors = [];
5657
5962
  if (typeof value.src !== "string" || value.src.trim() === "") {
5658
- errors.push({ fieldPath: `${path19}.src`, message: "Image src is required" });
5963
+ errors.push({ fieldPath: `${path20}.src`, message: "Image src is required" });
5659
5964
  }
5660
5965
  if (typeof value.alt !== "string") {
5661
- errors.push({ fieldPath: `${path19}.alt`, message: "Image alt text is required" });
5966
+ errors.push({ fieldPath: `${path20}.alt`, message: "Image alt text is required" });
5662
5967
  } else if (value.alt.trim() === "" && field.altOptional !== true) {
5663
- errors.push({ fieldPath: `${path19}.alt`, message: "Image alt text is required" });
5968
+ errors.push({ fieldPath: `${path20}.alt`, message: "Image alt text is required" });
5664
5969
  }
5665
5970
  if (value.width !== void 0 && !isPositiveInt(value.width)) {
5666
- errors.push({ fieldPath: `${path19}.width`, message: "Image width must be a positive integer" });
5971
+ errors.push({ fieldPath: `${path20}.width`, message: "Image width must be a positive integer" });
5667
5972
  }
5668
5973
  if (value.height !== void 0 && !isPositiveInt(value.height)) {
5669
5974
  errors.push({
5670
- fieldPath: `${path19}.height`,
5975
+ fieldPath: `${path20}.height`,
5671
5976
  message: "Image height must be a positive integer"
5672
5977
  });
5673
5978
  }
5674
5979
  if (value.crop !== void 0 && !isValidImageCropValue(value.crop)) {
5675
- errors.push({ fieldPath: `${path19}.crop`, message: "Invalid image crop rect" });
5980
+ errors.push({ fieldPath: `${path20}.crop`, message: "Invalid image crop rect" });
5676
5981
  }
5677
5982
  return errors;
5678
5983
  }
@@ -5684,44 +5989,44 @@ function referenceValueId(value) {
5684
5989
  return void 0;
5685
5990
  }
5686
5991
  var selectOptionValues = (field) => (field.options ?? []).map((opt) => typeof opt === "string" ? opt : opt.value);
5687
- function validateScalar(field, value, path19) {
5992
+ function validateScalar(field, value, path20) {
5688
5993
  switch (field.type) {
5689
5994
  case "number":
5690
5995
  if (typeof value !== "number" || !Number.isFinite(value)) {
5691
- return [{ fieldPath: path19, message: "Expected a number" }];
5996
+ return [{ fieldPath: path20, message: "Expected a number" }];
5692
5997
  }
5693
5998
  return [];
5694
5999
  case "boolean":
5695
6000
  if (typeof value !== "boolean") {
5696
- return [{ fieldPath: path19, message: "Expected true or false" }];
6001
+ return [{ fieldPath: path20, message: "Expected true or false" }];
5697
6002
  }
5698
6003
  return [];
5699
6004
  case "datetime":
5700
6005
  if (typeof value !== "string" || value !== "" && Number.isNaN(Date.parse(value))) {
5701
- return [{ fieldPath: path19, message: "Expected a valid date/time string" }];
6006
+ return [{ fieldPath: path20, message: "Expected a valid date/time string" }];
5702
6007
  }
5703
6008
  return [];
5704
6009
  case "select": {
5705
6010
  if (typeof value !== "string") {
5706
- return [{ fieldPath: path19, message: "Expected a selection" }];
6011
+ return [{ fieldPath: path20, message: "Expected a selection" }];
5707
6012
  }
5708
6013
  const options = selectOptionValues(field);
5709
6014
  if (value !== "" && !options.includes(value)) {
5710
- return [{ fieldPath: path19, message: `Must be one of: ${options.join(", ")}` }];
6015
+ return [{ fieldPath: path20, message: `Must be one of: ${options.join(", ")}` }];
5711
6016
  }
5712
6017
  return [];
5713
6018
  }
5714
6019
  case "reference":
5715
6020
  if (referenceValueId(value) === void 0) {
5716
- return [{ fieldPath: path19, message: "Expected a reference id" }];
6021
+ return [{ fieldPath: path20, message: "Expected a reference id" }];
5717
6022
  }
5718
6023
  return [];
5719
6024
  case "image":
5720
- return validateImageValue(field, value, path19);
6025
+ return validateImageValue(field, value, path20);
5721
6026
  default:
5722
6027
  if (STRING_FIELD_TYPES.has(field.type)) {
5723
6028
  if (typeof value !== "string") {
5724
- return [{ fieldPath: path19, message: "Expected text" }];
6029
+ return [{ fieldPath: path20, message: "Expected text" }];
5725
6030
  }
5726
6031
  }
5727
6032
  return [];
@@ -5744,12 +6049,12 @@ function validateEntryData(fields, data, pathPrefix = "") {
5744
6049
  errors.push(...validateEntryData(field.fields, data, pathPrefix));
5745
6050
  continue;
5746
6051
  }
5747
- const path19 = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
6052
+ const path20 = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
5748
6053
  const value = data[field.name];
5749
6054
  const isRequired = "required" in field && field.required === true;
5750
6055
  if (isRequired && isEmptyForRequired(field, value)) {
5751
6056
  const message = Array.isArray(value) ? "At least one item is required" : "This field is required";
5752
- errors.push({ fieldPath: path19, message });
6057
+ errors.push({ fieldPath: path20, message });
5753
6058
  continue;
5754
6059
  }
5755
6060
  if (value === void 0 || value === null)
@@ -5757,11 +6062,11 @@ function validateEntryData(fields, data, pathPrefix = "") {
5757
6062
  if (field.type === "block") {
5758
6063
  const blockField = field;
5759
6064
  if (!Array.isArray(value)) {
5760
- errors.push({ fieldPath: path19, message: "Expected a list of blocks" });
6065
+ errors.push({ fieldPath: path20, message: "Expected a list of blocks" });
5761
6066
  continue;
5762
6067
  }
5763
6068
  value.forEach((item, index) => {
5764
- const itemPath = `${path19}[${index}]`;
6069
+ const itemPath = `${path20}[${index}]`;
5765
6070
  if (!isPlainRecord2(item)) {
5766
6071
  errors.push({ fieldPath: itemPath, message: "Expected a block object" });
5767
6072
  return;
@@ -5783,11 +6088,11 @@ function validateEntryData(fields, data, pathPrefix = "") {
5783
6088
  const objectField = field;
5784
6089
  if (objectField.list) {
5785
6090
  if (!Array.isArray(value)) {
5786
- errors.push({ fieldPath: path19, message: "Expected a list of items" });
6091
+ errors.push({ fieldPath: path20, message: "Expected a list of items" });
5787
6092
  continue;
5788
6093
  }
5789
6094
  value.forEach((item, index) => {
5790
- const itemPath = `${path19}[${index}]`;
6095
+ const itemPath = `${path20}[${index}]`;
5791
6096
  if (!isPlainRecord2(item)) {
5792
6097
  errors.push({ fieldPath: itemPath, message: "Expected an object" });
5793
6098
  return;
@@ -5797,27 +6102,27 @@ function validateEntryData(fields, data, pathPrefix = "") {
5797
6102
  }
5798
6103
  });
5799
6104
  } else if (!isPlainRecord2(value)) {
5800
- errors.push({ fieldPath: path19, message: "Expected an object" });
6105
+ errors.push({ fieldPath: path20, message: "Expected an object" });
5801
6106
  } else if (objectField.fields) {
5802
- errors.push(...validateEntryData(objectField.fields, value, path19));
6107
+ errors.push(...validateEntryData(objectField.fields, value, path20));
5803
6108
  }
5804
6109
  continue;
5805
6110
  }
5806
6111
  if ("list" in field && field.list) {
5807
6112
  if (!Array.isArray(value)) {
5808
- errors.push({ fieldPath: path19, message: "Expected a list of values" });
6113
+ errors.push({ fieldPath: path20, message: "Expected a list of values" });
5809
6114
  continue;
5810
6115
  }
5811
6116
  value.forEach((item, index) => {
5812
- errors.push(...validateScalar(field, item, `${path19}[${index}]`));
6117
+ errors.push(...validateScalar(field, item, `${path20}[${index}]`));
5813
6118
  });
5814
6119
  continue;
5815
6120
  }
5816
6121
  if (Array.isArray(value) && KNOWN_FIELD_TYPES.has(field.type)) {
5817
- errors.push({ fieldPath: path19, message: "Expected a single value, not a list" });
6122
+ errors.push({ fieldPath: path20, message: "Expected a single value, not a list" });
5818
6123
  continue;
5819
6124
  }
5820
- errors.push(...validateScalar(field, value, path19));
6125
+ errors.push(...validateScalar(field, value, path20));
5821
6126
  }
5822
6127
  return errors;
5823
6128
  }
@@ -5887,14 +6192,14 @@ async function generateAIContentFiles(options) {
5887
6192
  config: aiConfig,
5888
6193
  entryLinkUrl: config.entryLinkUrl
5889
6194
  });
5890
- const absoluteOutputDir = path17.resolve(outputDir) + path17.sep;
6195
+ const absoluteOutputDir = path18.resolve(outputDir) + path18.sep;
5891
6196
  let fileCount = 0;
5892
6197
  for (const [filePath, content] of result.files) {
5893
- const absolutePath = path17.resolve(path17.join(absoluteOutputDir, filePath));
6198
+ const absolutePath = path18.resolve(path18.join(absoluteOutputDir, filePath));
5894
6199
  if (!absolutePath.startsWith(absoluteOutputDir)) {
5895
6200
  throw new Error(`Path traversal detected in AI content output: ${filePath}`);
5896
6201
  }
5897
- await fs16.mkdir(path17.dirname(absolutePath), { recursive: true });
6202
+ await fs16.mkdir(path18.dirname(absolutePath), { recursive: true });
5898
6203
  await fs16.writeFile(absolutePath, content, "utf-8");
5899
6204
  fileCount++;
5900
6205
  }
@@ -5906,7 +6211,7 @@ var jiti = createJiti(import.meta.url);
5906
6211
  async function generateAIContentCLI(options) {
5907
6212
  const { projectDir, outputDir = "public/ai", configPath, appDir = "app" } = options;
5908
6213
  console.log("\nCanopyCMS generate-ai-content\n");
5909
- const canopyConfigPath = path18.join(projectDir, "canopycms.config.ts");
6214
+ const canopyConfigPath = path19.join(projectDir, "canopycms.config.ts");
5910
6215
  let canopyConfigModule;
5911
6216
  try {
5912
6217
  canopyConfigModule = await jiti.import(canopyConfigPath);
@@ -5917,7 +6222,7 @@ async function generateAIContentCLI(options) {
5917
6222
  }
5918
6223
  const configExport = canopyConfigModule.default ?? canopyConfigModule.config ?? canopyConfigModule;
5919
6224
  const serverConfig = typeof configExport === "object" && configExport !== null && "server" in configExport ? configExport.server : configExport;
5920
- const schemasPath = path18.join(projectDir, appDir, "schemas.ts");
6225
+ const schemasPath = path19.join(projectDir, appDir, "schemas.ts");
5921
6226
  let entrySchemaRegistry = {};
5922
6227
  try {
5923
6228
  const schemasModule = await jiti.import(schemasPath);
@@ -5928,7 +6233,7 @@ async function generateAIContentCLI(options) {
5928
6233
  let aiConfig;
5929
6234
  if (configPath) {
5930
6235
  try {
5931
- const aiConfigModule = await jiti.import(path18.resolve(configPath));
6236
+ const aiConfigModule = await jiti.import(path19.resolve(configPath));
5932
6237
  aiConfig = aiConfigModule.aiContentConfig ?? aiConfigModule.default ?? aiConfigModule.config;
5933
6238
  } catch (err) {
5934
6239
  console.error(`Could not load AI config from ${configPath}`);
@@ -5945,7 +6250,7 @@ async function generateAIContentCLI(options) {
5945
6250
  console.error("Make sure canopycms.config.ts uses defineCanopyConfig().");
5946
6251
  process.exit(1);
5947
6252
  }
5948
- const resolvedOutput = path18.resolve(projectDir, outputDir);
6253
+ const resolvedOutput = path19.resolve(projectDir, outputDir);
5949
6254
  console.log(` Output: ${resolvedOutput}`);
5950
6255
  console.log(` Mode: ${serverConfig.mode ?? "dev"}`);
5951
6256
  const result = await generateAIContentFiles({