canopycms 0.0.54 → 0.0.56

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 (225) hide show
  1. package/dist/api/assets.d.ts +14 -2
  2. package/dist/api/assets.d.ts.map +1 -1
  3. package/dist/api/assets.js +14 -3
  4. package/dist/api/assets.js.map +1 -1
  5. package/dist/api/branch-status.d.ts.map +1 -1
  6. package/dist/api/branch-status.js +8 -2
  7. package/dist/api/branch-status.js.map +1 -1
  8. package/dist/api/client.d.ts +2 -2
  9. package/dist/api/client.d.ts.map +1 -1
  10. package/dist/api/client.js +29 -14
  11. package/dist/api/client.js.map +1 -1
  12. package/dist/api/content.d.ts +2 -2
  13. package/dist/api/content.d.ts.map +1 -1
  14. package/dist/api/content.js +160 -26
  15. package/dist/api/content.js.map +1 -1
  16. package/dist/api/entries.d.ts.map +1 -1
  17. package/dist/api/entries.js +8 -4
  18. package/dist/api/entries.js.map +1 -1
  19. package/dist/api/github-sync.d.ts.map +1 -1
  20. package/dist/api/github-sync.js +45 -7
  21. package/dist/api/github-sync.js.map +1 -1
  22. package/dist/api/groups.d.ts.map +1 -1
  23. package/dist/api/groups.js +15 -5
  24. package/dist/api/groups.js.map +1 -1
  25. package/dist/api/permissions.d.ts +6 -6
  26. package/dist/api/permissions.d.ts.map +1 -1
  27. package/dist/api/permissions.js +24 -9
  28. package/dist/api/permissions.js.map +1 -1
  29. package/dist/api/request-body-hash.d.ts +33 -0
  30. package/dist/api/request-body-hash.d.ts.map +1 -0
  31. package/dist/api/request-body-hash.js +43 -0
  32. package/dist/api/request-body-hash.js.map +1 -0
  33. package/dist/api/resolve-references.d.ts.map +1 -1
  34. package/dist/api/resolve-references.js +8 -1
  35. package/dist/api/resolve-references.js.map +1 -1
  36. package/dist/api/schema.js +9 -9
  37. package/dist/api/schema.js.map +1 -1
  38. package/dist/api/settings-helpers.d.ts +14 -1
  39. package/dist/api/settings-helpers.d.ts.map +1 -1
  40. package/dist/api/settings-helpers.js +11 -2
  41. package/dist/api/settings-helpers.js.map +1 -1
  42. package/dist/api/types.d.ts +10 -0
  43. package/dist/api/types.d.ts.map +1 -1
  44. package/dist/auth/caching-auth-plugin.d.ts +8 -1
  45. package/dist/auth/caching-auth-plugin.d.ts.map +1 -1
  46. package/dist/auth/caching-auth-plugin.js +6 -1
  47. package/dist/auth/caching-auth-plugin.js.map +1 -1
  48. package/dist/auth/index.d.ts +1 -0
  49. package/dist/auth/index.d.ts.map +1 -1
  50. package/dist/auth/index.js +1 -0
  51. package/dist/auth/index.js.map +1 -1
  52. package/dist/auth/plugin.d.ts +35 -0
  53. package/dist/auth/plugin.d.ts.map +1 -1
  54. package/dist/auth/plugin.js +27 -1
  55. package/dist/auth/plugin.js.map +1 -1
  56. package/dist/authorization/helpers.d.ts +16 -0
  57. package/dist/authorization/helpers.d.ts.map +1 -1
  58. package/dist/authorization/helpers.js +18 -0
  59. package/dist/authorization/helpers.js.map +1 -1
  60. package/dist/authorization/index.d.ts +1 -1
  61. package/dist/authorization/index.d.ts.map +1 -1
  62. package/dist/authorization/index.js +1 -1
  63. package/dist/authorization/index.js.map +1 -1
  64. package/dist/build/generate-ai-content.d.ts.map +1 -1
  65. package/dist/build/generate-ai-content.js +7 -0
  66. package/dist/build/generate-ai-content.js.map +1 -1
  67. package/dist/cli/cli.d.ts.map +1 -1
  68. package/dist/cli/cli.js +2 -1
  69. package/dist/cli/cli.js.map +1 -1
  70. package/dist/cli/generate-ai-content.js +1150 -355
  71. package/dist/cli/init.d.ts +1 -1
  72. package/dist/cli/init.d.ts.map +1 -1
  73. package/dist/cli/init.js +40 -13
  74. package/dist/cli/init.js.map +1 -1
  75. package/dist/cli/sync.d.ts.map +1 -1
  76. package/dist/cli/sync.js +88 -60
  77. package/dist/cli/sync.js.map +1 -1
  78. package/dist/cli/template-files/canopy.ts.template +2 -1
  79. package/dist/cli/template-files/edit-page.tsx.template +8 -1
  80. package/dist/cli/template-files/middleware.ts.template +18 -0
  81. package/dist/cli/templates.d.ts.map +1 -1
  82. package/dist/cli/templates.js +19 -4
  83. package/dist/cli/templates.js.map +1 -1
  84. package/dist/config/helpers.d.ts +9 -1
  85. package/dist/config/helpers.d.ts.map +1 -1
  86. package/dist/config/helpers.js +33 -75
  87. package/dist/config/helpers.js.map +1 -1
  88. package/dist/config/schemas/config.d.ts +8 -24
  89. package/dist/config/schemas/config.d.ts.map +1 -1
  90. package/dist/config/schemas/config.js +12 -5
  91. package/dist/config/schemas/config.js.map +1 -1
  92. package/dist/config/schemas/media.d.ts +1 -10
  93. package/dist/config/schemas/media.d.ts.map +1 -1
  94. package/dist/config/schemas/media.js +8 -6
  95. package/dist/config/schemas/media.js.map +1 -1
  96. package/dist/config/types.d.ts +6 -4
  97. package/dist/config/types.d.ts.map +1 -1
  98. package/dist/config-test.d.ts +5 -3
  99. package/dist/config-test.d.ts.map +1 -1
  100. package/dist/config-test.js +9 -1
  101. package/dist/config-test.js.map +1 -1
  102. package/dist/content-id-index.d.ts +12 -6
  103. package/dist/content-id-index.d.ts.map +1 -1
  104. package/dist/content-id-index.js +12 -6
  105. package/dist/content-id-index.js.map +1 -1
  106. package/dist/content-index-generation.d.ts +30 -0
  107. package/dist/content-index-generation.d.ts.map +1 -0
  108. package/dist/content-index-generation.js +131 -0
  109. package/dist/content-index-generation.js.map +1 -0
  110. package/dist/content-index-registry.d.ts +38 -0
  111. package/dist/content-index-registry.d.ts.map +1 -0
  112. package/dist/content-index-registry.js +53 -0
  113. package/dist/content-index-registry.js.map +1 -0
  114. package/dist/content-listing.d.ts +3 -1
  115. package/dist/content-listing.d.ts.map +1 -1
  116. package/dist/content-listing.js +1 -0
  117. package/dist/content-listing.js.map +1 -1
  118. package/dist/content-store.d.ts +130 -6
  119. package/dist/content-store.d.ts.map +1 -1
  120. package/dist/content-store.js +343 -31
  121. package/dist/content-store.js.map +1 -1
  122. package/dist/editor/Editor.d.ts.map +1 -1
  123. package/dist/editor/Editor.js +27 -19
  124. package/dist/editor/Editor.js.map +1 -1
  125. package/dist/editor/FormRenderer.d.ts +7 -0
  126. package/dist/editor/FormRenderer.d.ts.map +1 -1
  127. package/dist/editor/FormRenderer.js +12 -3
  128. package/dist/editor/FormRenderer.js.map +1 -1
  129. package/dist/editor/components/EditorHeader.d.ts.map +1 -1
  130. package/dist/editor/components/EditorHeader.js +3 -4
  131. package/dist/editor/components/EditorHeader.js.map +1 -1
  132. package/dist/editor/components/EditorSidebar.d.ts.map +1 -1
  133. package/dist/editor/components/EditorSidebar.js +2 -4
  134. package/dist/editor/components/EditorSidebar.js.map +1 -1
  135. package/dist/editor/fields/ReferenceField.d.ts.map +1 -1
  136. package/dist/editor/fields/ReferenceField.js +19 -2
  137. package/dist/editor/fields/ReferenceField.js.map +1 -1
  138. package/dist/editor/group-manager/hooks/useGroupState.d.ts.map +1 -1
  139. package/dist/editor/group-manager/hooks/useGroupState.js +5 -2
  140. package/dist/editor/group-manager/hooks/useGroupState.js.map +1 -1
  141. package/dist/editor/hooks/useDraftManager.d.ts +15 -0
  142. package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
  143. package/dist/editor/hooks/useDraftManager.js +140 -1
  144. package/dist/editor/hooks/useDraftManager.js.map +1 -1
  145. package/dist/editor/hooks/useEntryManager.d.ts +9 -2
  146. package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
  147. package/dist/editor/hooks/useEntryManager.js +9 -3
  148. package/dist/editor/hooks/useEntryManager.js.map +1 -1
  149. package/dist/editor/hooks/useSchemaManager.d.ts +34 -10
  150. package/dist/editor/hooks/useSchemaManager.d.ts.map +1 -1
  151. package/dist/editor/hooks/useSchemaManager.js +22 -25
  152. package/dist/editor/hooks/useSchemaManager.js.map +1 -1
  153. package/dist/editor/schema-editor/CollectionEditor.d.ts +6 -5
  154. package/dist/editor/schema-editor/CollectionEditor.d.ts.map +1 -1
  155. package/dist/editor/schema-editor/CollectionEditor.js +37 -12
  156. package/dist/editor/schema-editor/CollectionEditor.js.map +1 -1
  157. package/dist/entry-link-resolver.d.ts.map +1 -1
  158. package/dist/entry-link-resolver.js +5 -0
  159. package/dist/entry-link-resolver.js.map +1 -1
  160. package/dist/git-manager.d.ts +38 -3
  161. package/dist/git-manager.d.ts.map +1 -1
  162. package/dist/git-manager.js +141 -33
  163. package/dist/git-manager.js.map +1 -1
  164. package/dist/github-service.d.ts +69 -3
  165. package/dist/github-service.d.ts.map +1 -1
  166. package/dist/github-service.js +149 -25
  167. package/dist/github-service.js.map +1 -1
  168. package/dist/http/handler.d.ts +1 -1
  169. package/dist/http/handler.d.ts.map +1 -1
  170. package/dist/http/handler.js +36 -7
  171. package/dist/http/handler.js.map +1 -1
  172. package/dist/http/router.d.ts +11 -0
  173. package/dist/http/router.d.ts.map +1 -1
  174. package/dist/http/router.js +80 -16
  175. package/dist/http/router.js.map +1 -1
  176. package/dist/paths/validation.d.ts.map +1 -1
  177. package/dist/paths/validation.js +12 -0
  178. package/dist/paths/validation.js.map +1 -1
  179. package/dist/schema/schema-store.d.ts +9 -0
  180. package/dist/schema/schema-store.d.ts.map +1 -1
  181. package/dist/schema/schema-store.js +57 -8
  182. package/dist/schema/schema-store.js.map +1 -1
  183. package/dist/services.d.ts +1 -0
  184. package/dist/services.d.ts.map +1 -1
  185. package/dist/services.js +9 -2
  186. package/dist/services.js.map +1 -1
  187. package/dist/static/index.d.ts +32 -0
  188. package/dist/static/index.d.ts.map +1 -1
  189. package/dist/static/index.js +90 -0
  190. package/dist/static/index.js.map +1 -1
  191. package/dist/sync-core.d.ts.map +1 -1
  192. package/dist/sync-core.js +22 -9
  193. package/dist/sync-core.js.map +1 -1
  194. package/dist/user.d.ts +0 -18
  195. package/dist/user.d.ts.map +1 -1
  196. package/dist/user.js +7 -22
  197. package/dist/user.js.map +1 -1
  198. package/dist/utils/error.d.ts +8 -0
  199. package/dist/utils/error.d.ts.map +1 -1
  200. package/dist/utils/error.js +35 -7
  201. package/dist/utils/error.js.map +1 -1
  202. package/dist/validation/deletion-checker.d.ts.map +1 -1
  203. package/dist/validation/deletion-checker.js +6 -5
  204. package/dist/validation/deletion-checker.js.map +1 -1
  205. package/dist/validation/entry-validator.d.ts +67 -0
  206. package/dist/validation/entry-validator.d.ts.map +1 -0
  207. package/dist/validation/entry-validator.js +277 -0
  208. package/dist/validation/entry-validator.js.map +1 -0
  209. package/dist/validation/field-traversal.d.ts +23 -1
  210. package/dist/validation/field-traversal.d.ts.map +1 -1
  211. package/dist/validation/field-traversal.js +29 -9
  212. package/dist/validation/field-traversal.js.map +1 -1
  213. package/dist/worker/cms-worker.d.ts +70 -6
  214. package/dist/worker/cms-worker.d.ts.map +1 -1
  215. package/dist/worker/cms-worker.js +199 -93
  216. package/dist/worker/cms-worker.js.map +1 -1
  217. package/package.json +2 -2
  218. package/dist/editor/components/EditorContext.d.ts +0 -35
  219. package/dist/editor/components/EditorContext.d.ts.map +0 -1
  220. package/dist/editor/components/EditorContext.js +0 -19
  221. package/dist/editor/components/EditorContext.js.map +0 -1
  222. package/dist/editor/env.d.ts +0 -5
  223. package/dist/editor/env.d.ts.map +0 -1
  224. package/dist/editor/env.js +0 -5
  225. package/dist/editor/env.js.map +0 -1
@@ -9,11 +9,11 @@ var __export = (target, all) => {
9
9
  };
10
10
 
11
11
  // dist/paths/normalize.js
12
- function normalizeFilesystemPath(path15) {
13
- return path15.split(/[\\/]+/).filter(Boolean).join("/");
12
+ function normalizeFilesystemPath(path18) {
13
+ return path18.split(/[\\/]+/).filter(Boolean).join("/");
14
14
  }
15
- function hasTraversalSequence(path15) {
16
- const normalized = normalizeFilesystemPath(path15);
15
+ function hasTraversalSequence(path18) {
16
+ const normalized = normalizeFilesystemPath(path18);
17
17
  return normalized.includes("..");
18
18
  }
19
19
  function createLogicalPath(...segments) {
@@ -23,14 +23,14 @@ function createLogicalPath(...segments) {
23
23
  }
24
24
  return normalized;
25
25
  }
26
- function trimSlashes(path15) {
26
+ function trimSlashes(path18) {
27
27
  let start = 0;
28
- let end = path15.length;
29
- while (start < end && path15[start] === "/")
28
+ let end = path18.length;
29
+ while (start < end && path18[start] === "/")
30
30
  start++;
31
- while (end > start && path15[end - 1] === "/")
31
+ while (end > start && path18[end - 1] === "/")
32
32
  end--;
33
- return path15.slice(start, end);
33
+ return path18.slice(start, end);
34
34
  }
35
35
  var init_normalize = __esm({
36
36
  "dist/paths/normalize.js"() {
@@ -284,7 +284,7 @@ var mediaSchema;
284
284
  var init_media = __esm({
285
285
  "dist/config/schemas/media.js"() {
286
286
  "use strict";
287
- mediaSchema = z3.union([
287
+ mediaSchema = z3.discriminatedUnion("adapter", [
288
288
  z3.object({
289
289
  adapter: z3.literal("local"),
290
290
  publicBaseUrl: z3.string().url().optional()
@@ -298,10 +298,6 @@ var init_media = __esm({
298
298
  z3.object({
299
299
  adapter: z3.literal("lfs"),
300
300
  publicBaseUrl: z3.string().url().optional()
301
- }),
302
- z3.object({
303
- adapter: z3.string().min(1),
304
- publicBaseUrl: z3.string().url().optional()
305
301
  })
306
302
  ]);
307
303
  }
@@ -323,7 +319,7 @@ var init_config = __esm({
323
319
  gitBotAuthorNameSchema = z4.string().min(1);
324
320
  gitBotAuthorEmailSchema = z4.string().email();
325
321
  githubTokenEnvVarSchema = z4.string().default("GITHUB_BOT_TOKEN");
326
- operatingModeSchema = z4.enum(["prod", "dev"]).default("dev");
322
+ operatingModeSchema = z4.enum(["prod", "dev"]);
327
323
  deployedAsSchema = z4.enum(["static", "server"]).default("server");
328
324
  contentRootSchema = relativePathSchema.default("content");
329
325
  sourceRootSchema = z4.string().min(1).optional();
@@ -345,8 +341,12 @@ var init_config = __esm({
345
341
  });
346
342
  CanopyConfigSchema = z4.object({
347
343
  media: mediaSchema.optional(),
348
- defaultBranchAccess: defaultBranchAccessSchema.optional(),
349
- defaultPathAccess: defaultPathAccessSchema.optional(),
344
+ // No outer .optional() here: defaultBranchAccessSchema/defaultPathAccessSchema already
345
+ // have .default('deny'), so the field is optional on input but always resolves to
346
+ // 'allow'/'deny' (never undefined) on output. An outer .optional() would short-circuit
347
+ // before the inner default runs, defeating the fail-closed default (SCH-M1).
348
+ defaultBranchAccess: defaultBranchAccessSchema,
349
+ defaultPathAccess: defaultPathAccessSchema,
350
350
  // .optional() deliberately defeats defaultBaseBranchSchema's .default('main'):
351
351
  // unset must stay undefined after parsing so dev mode can detect the fork
352
352
  // point from git HEAD (see resolveBaseBranch in utils/git.ts).
@@ -357,8 +357,9 @@ var init_config = __esm({
357
357
  gitBotAuthorName: gitBotAuthorNameSchema,
358
358
  gitBotAuthorEmail: gitBotAuthorEmailSchema,
359
359
  githubTokenEnvVar: githubTokenEnvVarSchema.optional(),
360
+ // Required by design (follow-up to SEC-C1): a prod deploy that omits `mode` must fail
361
+ // validation loudly rather than silently running header-trusting dev auth semantics.
360
362
  mode: operatingModeSchema,
361
- // Has .default(), so not optional in output type
362
363
  deployedAs: deployedAsSchema,
363
364
  // Has .default('server'), so always present after validation
364
365
  settingsBranch: z4.string().optional(),
@@ -541,7 +542,7 @@ var init_config2 = __esm({
541
542
  });
542
543
 
543
544
  // dist/operating-mode/client-unsafe-strategy.js
544
- import path3 from "node:path";
545
+ import path5 from "node:path";
545
546
  function operatingStrategy(mode) {
546
547
  const cached = strategyCache.get(mode);
547
548
  if (cached)
@@ -578,32 +579,32 @@ var init_client_unsafe_strategy = __esm({
578
579
  // - shouldCommit(), shouldPush()
579
580
  // Add client-unsafe methods (use Node.js APIs)
580
581
  getWorkspaceRoot(_sourceRoot) {
581
- return path3.resolve(process.env.CANOPYCMS_WORKSPACE_ROOT ?? DEFAULT_PROD_WORKSPACE);
582
+ return path5.resolve(process.env.CANOPYCMS_WORKSPACE_ROOT ?? DEFAULT_PROD_WORKSPACE);
582
583
  }
583
584
  getContentRoot(sourceRoot) {
584
- return path3.resolve(sourceRoot ?? process.cwd(), "content");
585
+ return path5.resolve(sourceRoot ?? process.cwd(), "content");
585
586
  }
586
587
  getContentBranchesRoot(sourceRoot) {
587
- return path3.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
588
+ return path5.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
588
589
  }
589
590
  getContentBranchRoot(branchName, sourceRoot) {
590
- return path3.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
591
+ return path5.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
591
592
  }
592
593
  getGitExcludePattern() {
593
594
  return ".canopy-meta/";
594
595
  }
595
596
  getPermissionsFilePath(root) {
596
- return path3.join(root, this.getPermissionsFileName());
597
+ return path5.join(root, this.getPermissionsFileName());
597
598
  }
598
599
  getGroupsFilePath(root) {
599
- return path3.join(root, this.getGroupsFileName());
600
+ return path5.join(root, this.getGroupsFileName());
600
601
  }
601
602
  getRemoteUrlConfig() {
602
603
  return {
603
604
  shouldAutoInitLocal: false,
604
605
  defaultRemotePath: "",
605
606
  envVarName: "CANOPYCMS_REMOTE_URL",
606
- autoDetectRemotePath: path3.join(this.getWorkspaceRoot(), "remote.git")
607
+ autoDetectRemotePath: path5.join(this.getWorkspaceRoot(), "remote.git")
607
608
  };
608
609
  }
609
610
  requiresExistingRepo() {
@@ -616,7 +617,7 @@ var init_client_unsafe_strategy = __esm({
616
617
  return `canopycms-settings-${deploymentName}`;
617
618
  }
618
619
  getSettingsRoot(sourceRoot) {
619
- return path3.join(this.getWorkspaceRoot(sourceRoot), "settings");
620
+ return path5.join(this.getWorkspaceRoot(sourceRoot), "settings");
620
621
  }
621
622
  usesSeparateSettingsBranch() {
622
623
  return true;
@@ -633,25 +634,25 @@ var init_client_unsafe_strategy = __esm({
633
634
  DevStrategy = class extends DevClientSafeStrategy {
634
635
  // Inherits client-safe methods from DevClientSafeStrategy
635
636
  getWorkspaceRoot(sourceRoot) {
636
- return path3.resolve(sourceRoot ?? process.cwd(), ".canopy-dev");
637
+ return path5.resolve(sourceRoot ?? process.cwd(), ".canopy-dev");
637
638
  }
638
639
  getContentRoot(sourceRoot) {
639
- return path3.resolve(sourceRoot ?? process.cwd(), "content");
640
+ return path5.resolve(sourceRoot ?? process.cwd(), "content");
640
641
  }
641
642
  getContentBranchesRoot(sourceRoot) {
642
- return path3.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
643
+ return path5.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
643
644
  }
644
645
  getContentBranchRoot(branchName, sourceRoot) {
645
- return path3.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
646
+ return path5.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
646
647
  }
647
648
  getGitExcludePattern() {
648
649
  return ".canopy-meta/";
649
650
  }
650
651
  getPermissionsFilePath(root) {
651
- return path3.join(root, this.getPermissionsFileName());
652
+ return path5.join(root, this.getPermissionsFileName());
652
653
  }
653
654
  getGroupsFilePath(root) {
654
- return path3.join(root, this.getGroupsFileName());
655
+ return path5.join(root, this.getGroupsFileName());
655
656
  }
656
657
  getRemoteUrlConfig() {
657
658
  return {
@@ -670,7 +671,7 @@ var init_client_unsafe_strategy = __esm({
670
671
  return `canopycms-settings-${deploymentName}`;
671
672
  }
672
673
  getSettingsRoot(sourceRoot) {
673
- return path3.join(this.getWorkspaceRoot(sourceRoot), "settings");
674
+ return path5.join(this.getWorkspaceRoot(sourceRoot), "settings");
674
675
  }
675
676
  usesSeparateSettingsBranch() {
676
677
  return true;
@@ -702,16 +703,16 @@ var init_operating_mode = __esm({
702
703
  });
703
704
 
704
705
  // dist/cli/generate-ai-content.js
705
- import path14 from "node:path";
706
+ import path17 from "node:path";
706
707
  import { createJiti } from "jiti";
707
708
 
708
709
  // dist/build/generate-ai-content.js
709
- import fs12 from "node:fs/promises";
710
- import path13 from "node:path";
710
+ import fs15 from "node:fs/promises";
711
+ import path16 from "node:path";
711
712
 
712
713
  // dist/content-store.js
713
- import fs4 from "node:fs/promises";
714
- import path5 from "node:path";
714
+ import fs6 from "node:fs/promises";
715
+ import path7 from "node:path";
715
716
  import matter from "gray-matter";
716
717
  import { parse as yamlParse, stringify as yamlStringify } from "yaml";
717
718
 
@@ -736,16 +737,16 @@ async function atomicWriteFile(filePath, content) {
736
737
  var locks = /* @__PURE__ */ new Map();
737
738
  async function withLock(key, fn) {
738
739
  const prev = locks.get(key) ?? Promise.resolve();
739
- let resolve;
740
+ let resolve2;
740
741
  const next = new Promise((r) => {
741
- resolve = r;
742
+ resolve2 = r;
742
743
  });
743
744
  locks.set(key, next);
744
745
  await prev;
745
746
  try {
746
747
  return await fn();
747
748
  } finally {
748
- resolve();
749
+ resolve2();
749
750
  if (locks.get(key) === next)
750
751
  locks.delete(key);
751
752
  }
@@ -1076,13 +1077,13 @@ function extractIdFromFilename(filename) {
1076
1077
  return null;
1077
1078
  }
1078
1079
  async function resolveCollectionPath(root, logicalPath) {
1079
- const fs13 = await import("node:fs/promises");
1080
- const path15 = await import("node:path");
1080
+ const fs16 = await import("node:fs/promises");
1081
+ const path18 = await import("node:path");
1081
1082
  const segments = logicalPath.split("/").filter(Boolean);
1082
1083
  let currentPath = root;
1083
1084
  for (const segment of segments) {
1084
1085
  try {
1085
- const entries = await fs13.readdir(currentPath, { withFileTypes: true });
1086
+ const entries = await fs16.readdir(currentPath, { withFileTypes: true });
1086
1087
  const matchingDir = entries.find((entry) => {
1087
1088
  if (!entry.isDirectory())
1088
1089
  return false;
@@ -1090,7 +1091,7 @@ async function resolveCollectionPath(root, logicalPath) {
1090
1091
  return logicalName === segment.toLowerCase();
1091
1092
  });
1092
1093
  if (matchingDir) {
1093
- currentPath = path15.join(currentPath, matchingDir.name);
1094
+ currentPath = path18.join(currentPath, matchingDir.name);
1094
1095
  } else {
1095
1096
  return null;
1096
1097
  }
@@ -1140,6 +1141,192 @@ function extractSlugFromFilename(filename, entryTypeName) {
1140
1141
  return filename.toLowerCase();
1141
1142
  }
1142
1143
 
1144
+ // dist/content-index-registry.js
1145
+ import path3 from "node:path";
1146
+ var registry = /* @__PURE__ */ new Map();
1147
+ var finalization = new FinalizationRegistry(({ rootKey, ref }) => {
1148
+ const refs = registry.get(rootKey);
1149
+ if (!refs)
1150
+ return;
1151
+ refs.delete(ref);
1152
+ if (refs.size === 0)
1153
+ registry.delete(rootKey);
1154
+ });
1155
+ function registerContentIndexForInvalidation(root, target) {
1156
+ const rootKey = path3.resolve(root);
1157
+ let refs = registry.get(rootKey);
1158
+ if (!refs) {
1159
+ refs = /* @__PURE__ */ new Set();
1160
+ registry.set(rootKey, refs);
1161
+ }
1162
+ const ref = new WeakRef(target);
1163
+ refs.add(ref);
1164
+ finalization.register(target, { rootKey, ref });
1165
+ }
1166
+ function invalidateContentIndexesForRoot(root) {
1167
+ const rootKey = path3.resolve(root);
1168
+ const prefix = rootKey + path3.sep;
1169
+ for (const [storeRoot, refs] of registry) {
1170
+ if (storeRoot !== rootKey && !storeRoot.startsWith(prefix))
1171
+ continue;
1172
+ for (const ref of refs) {
1173
+ const target = ref.deref();
1174
+ if (target) {
1175
+ target.invalidateIndex();
1176
+ } else {
1177
+ refs.delete(ref);
1178
+ }
1179
+ }
1180
+ if (refs.size === 0)
1181
+ registry.delete(storeRoot);
1182
+ }
1183
+ }
1184
+
1185
+ // dist/content-index-generation.js
1186
+ import fs3 from "node:fs/promises";
1187
+ import path4 from "node:path";
1188
+ import { randomUUID } from "node:crypto";
1189
+
1190
+ // dist/utils/debug.js
1191
+ var LOG_LEVELS = {
1192
+ DEBUG: 0,
1193
+ INFO: 1,
1194
+ WARN: 2,
1195
+ ERROR: 3
1196
+ };
1197
+ var DebugLogger = class {
1198
+ constructor(options = {}) {
1199
+ this.timers = /* @__PURE__ */ new Map();
1200
+ this.options = options;
1201
+ }
1202
+ shouldLog(level) {
1203
+ const enabled = this.options.enabled ?? process.env.CANOPYCMS_DEBUG === "true";
1204
+ if (!enabled)
1205
+ return false;
1206
+ const minLevel = this.options.minLevel ?? "DEBUG";
1207
+ return LOG_LEVELS[level] >= LOG_LEVELS[minLevel];
1208
+ }
1209
+ formatMessage(level, category, message) {
1210
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1211
+ const prefix = this.options.prefix ?? "CanopyCMS";
1212
+ return `[${timestamp}] [${prefix}:${category}] [${level}] ${message}`;
1213
+ }
1214
+ debug(category, message, data) {
1215
+ if (this.shouldLog("DEBUG")) {
1216
+ console.log(this.formatMessage("DEBUG", category, message), data ?? "");
1217
+ }
1218
+ }
1219
+ info(category, message, data) {
1220
+ if (this.shouldLog("INFO")) {
1221
+ console.log(this.formatMessage("INFO", category, message), data ?? "");
1222
+ }
1223
+ }
1224
+ warn(category, message, data) {
1225
+ if (this.shouldLog("WARN")) {
1226
+ console.warn(this.formatMessage("WARN", category, message), data ?? "");
1227
+ }
1228
+ }
1229
+ error(category, message, data) {
1230
+ const msg = this.formatMessage("ERROR", category, message);
1231
+ if (this.shouldLog("ERROR")) {
1232
+ console.error(msg, data ?? "");
1233
+ }
1234
+ const throwOnError = this.options.throwOnError ?? false;
1235
+ if (throwOnError) {
1236
+ const errorMsg = data ? `${message}: ${JSON.stringify(data)}` : message;
1237
+ throw new Error(errorMsg);
1238
+ }
1239
+ }
1240
+ /**
1241
+ * Start timing an operation
1242
+ */
1243
+ time(label) {
1244
+ this.timers.set(label, Date.now());
1245
+ }
1246
+ /**
1247
+ * End timing an operation and log the duration
1248
+ */
1249
+ timeEnd(category, label) {
1250
+ const start = this.timers.get(label);
1251
+ if (start === void 0) {
1252
+ this.warn(category, `Timer '${label}' does not exist`);
1253
+ return;
1254
+ }
1255
+ const duration = Date.now() - start;
1256
+ this.timers.delete(label);
1257
+ this.debug(category, `${label} completed`, { durationMs: duration });
1258
+ return duration;
1259
+ }
1260
+ /**
1261
+ * Wrap an async function with automatic timing
1262
+ */
1263
+ async timed(category, label, fn) {
1264
+ this.time(label);
1265
+ try {
1266
+ return await fn();
1267
+ } finally {
1268
+ this.timeEnd(category, label);
1269
+ }
1270
+ }
1271
+ };
1272
+ function createDebugLogger(options) {
1273
+ return new DebugLogger(options);
1274
+ }
1275
+ var testLogger = createDebugLogger({
1276
+ enabled: process.env.E2E_DEBUG === "true",
1277
+ prefix: "E2E",
1278
+ throwOnError: false
1279
+ });
1280
+
1281
+ // dist/content-index-generation.js
1282
+ var log = createDebugLogger({ prefix: "ContentIndex" });
1283
+ var META_DIR = ".canopy-meta";
1284
+ var GENERATION_FILE = "content-index.generation";
1285
+ function contentIndexGenerationPath(root) {
1286
+ return path4.join(path4.resolve(root), META_DIR, GENERATION_FILE);
1287
+ }
1288
+ async function bumpContentIndexGeneration(root) {
1289
+ const token = randomUUID();
1290
+ try {
1291
+ await atomicWriteFile(contentIndexGenerationPath(root), token);
1292
+ return token;
1293
+ } catch (err) {
1294
+ log.warn("generation", `Failed to bump content index generation for ${root}`, {
1295
+ error: getErrorMessage(err)
1296
+ });
1297
+ return null;
1298
+ }
1299
+ }
1300
+ async function readContentIndexGeneration(root) {
1301
+ try {
1302
+ return await fs3.readFile(contentIndexGenerationPath(root), "utf-8");
1303
+ } catch (err) {
1304
+ if (isNodeError(err) && err.code === "ENOENT")
1305
+ return null;
1306
+ log.warn("generation", `Failed to read content index generation for ${root}`, {
1307
+ error: getErrorMessage(err)
1308
+ });
1309
+ return null;
1310
+ }
1311
+ }
1312
+ async function invalidateContentIndexesDurable(root) {
1313
+ await bumpContentIndexGeneration(root);
1314
+ invalidateContentIndexesForRoot(root);
1315
+ }
1316
+
1317
+ // dist/utils/fs.js
1318
+ import fs4 from "node:fs/promises";
1319
+ async function filePathExists(filePath) {
1320
+ try {
1321
+ await fs4.stat(filePath);
1322
+ return true;
1323
+ } catch (err) {
1324
+ if (isNotFoundError(err))
1325
+ return false;
1326
+ throw err;
1327
+ }
1328
+ }
1329
+
1143
1330
  // dist/utils/format.js
1144
1331
  var getFormatExtension = (format) => {
1145
1332
  if (format === "md")
@@ -1156,10 +1343,29 @@ var asRecord = (value) => value && typeof value === "object" && !Array.isArray(v
1156
1343
  // dist/paths/index.js
1157
1344
  init_normalize();
1158
1345
 
1346
+ // dist/paths/normalize-server.js
1347
+ import { sep, resolve, relative } from "node:path";
1348
+ function validateAndNormalizePath(root, target) {
1349
+ const resolvedRoot = resolve(root);
1350
+ const withSep = resolvedRoot.endsWith(sep) ? resolvedRoot : `${resolvedRoot}${sep}`;
1351
+ const resolvedTarget = resolve(target);
1352
+ if (!resolvedTarget.startsWith(withSep) && resolvedTarget !== resolvedRoot) {
1353
+ return {
1354
+ valid: false,
1355
+ error: "Path traversal detected"
1356
+ };
1357
+ }
1358
+ const relativePath = relative(resolvedRoot, resolvedTarget).split(sep).join("/");
1359
+ return {
1360
+ valid: true,
1361
+ normalizedPath: relativePath
1362
+ };
1363
+ }
1364
+
1159
1365
  // dist/paths/branch.js
1160
1366
  init_operating_mode();
1161
- import fs3 from "node:fs/promises";
1162
- import path4 from "node:path";
1367
+ import fs5 from "node:fs/promises";
1368
+ import path6 from "node:path";
1163
1369
  var BranchPathError = class extends Error {
1164
1370
  };
1165
1371
  function sanitizeBranchName(branchName) {
@@ -1178,11 +1384,11 @@ function resolveBranchPath(options) {
1178
1384
  const safeBranch = sanitizeBranchName(options.branchName);
1179
1385
  const strategy = operatingStrategy(options.mode);
1180
1386
  const baseRoot = resolveContentBranchesRoot(options.mode, options.basePathOverride);
1181
- const normalizedBase = path4.resolve(baseRoot);
1182
- const baseWithSep = normalizedBase.endsWith(path4.sep) ? normalizedBase : `${normalizedBase}${path4.sep}`;
1387
+ const normalizedBase = path6.resolve(baseRoot);
1388
+ const baseWithSep = normalizedBase.endsWith(path6.sep) ? normalizedBase : `${normalizedBase}${path6.sep}`;
1183
1389
  const branchRoot = strategy.getContentBranchRoot(safeBranch, options.basePathOverride);
1184
1390
  const withinBase = (target) => {
1185
- const resolved = path4.resolve(target);
1391
+ const resolved = path6.resolve(target);
1186
1392
  return resolved === normalizedBase || resolved.startsWith(baseWithSep);
1187
1393
  };
1188
1394
  if (!withinBase(branchRoot)) {
@@ -1192,7 +1398,7 @@ function resolveBranchPath(options) {
1192
1398
  }
1193
1399
  async function ensureBranchRoot(options) {
1194
1400
  const result = resolveBranchPath(options);
1195
- await fs3.mkdir(result.branchRoot, { recursive: true });
1401
+ await fs5.mkdir(result.branchRoot, { recursive: true });
1196
1402
  return result;
1197
1403
  }
1198
1404
 
@@ -1222,24 +1428,147 @@ function validateSlug(slug) {
1222
1428
  throw new ContentStoreError("Slugs cannot contain backslashes. Use nested collections instead.", "VALIDATION");
1223
1429
  }
1224
1430
  }
1431
+ var DEFAULT_INDEX_FRESHNESS_INTERVAL_MS = 1e3;
1432
+ var FORCED_REFRESH_MIN_INTERVAL_MS = 5e3;
1433
+ var STALE_LOOKUP = Symbol("stale-index-lookup");
1225
1434
  var ContentStore = class {
1226
- constructor(root, flatSchema) {
1227
- this.indexLoaded = false;
1228
- this.root = path5.resolve(root);
1435
+ constructor(root, flatSchema, options = {}) {
1436
+ this.indexGeneration = 0;
1437
+ this.loadedIndexGeneration = -1;
1438
+ this.loadedDiskGeneration = void 0;
1439
+ this.lastDiskProbeMs = 0;
1440
+ this.lastForcedRefreshMs = 0;
1441
+ this.indexBuild = null;
1442
+ this.root = path7.resolve(root);
1443
+ this.indexFreshnessIntervalMs = options.indexFreshnessIntervalMs ?? DEFAULT_INDEX_FRESHNESS_INTERVAL_MS;
1229
1444
  this.schemaIndex = new Map(flatSchema.map((item) => [item.logicalPath, item]));
1230
1445
  this._idIndex = new ContentIdIndex(this.root);
1446
+ registerContentIndexForInvalidation(this.root, this);
1447
+ }
1448
+ /**
1449
+ * Mark the ID index stale so the next idIndex() access rebuilds it from disk.
1450
+ *
1451
+ * Called (via content-index-registry) after in-process operations that change the
1452
+ * working tree underneath this store — git checkout/merge/rebase, the worker's
1453
+ * rebase loop, and sync-core's content replacement. Without this, ID→path lookups
1454
+ * keep resolving to pre-mutation paths and saves can target moved/deleted files.
1455
+ *
1456
+ * Cheap: only bumps a generation counter; the rebuild happens lazily.
1457
+ */
1458
+ invalidateIndex() {
1459
+ this.indexGeneration++;
1231
1460
  }
1232
1461
  /**
1233
- * Get the ID index, ensuring it's loaded first.
1234
- * This getter automatically loads the index on first access.
1462
+ * Get the ID index, ensuring it's loaded and current first.
1463
+ * Loads lazily on first access and rebuilds after invalidateIndex(); repeated
1464
+ * accesses with no intervening invalidation reuse the already-built index,
1465
+ * except for a throttled probe of the on-disk generation marker that detects
1466
+ * mutations made by OTHER processes sharing this root (e.g. on EFS).
1235
1467
  */
1236
1468
  async idIndex() {
1237
- if (!this.indexLoaded) {
1238
- await this._idIndex.buildFromFilenames("content");
1239
- this.indexLoaded = true;
1469
+ if (this.loadedIndexGeneration === this.indexGeneration && this.shouldProbeDiskGeneration()) {
1470
+ const diskToken = await readContentIndexGeneration(this.root);
1471
+ if (diskToken !== this.loadedDiskGeneration) {
1472
+ this.indexGeneration++;
1473
+ }
1474
+ }
1475
+ while (this.loadedIndexGeneration !== this.indexGeneration) {
1476
+ if (this.indexBuild) {
1477
+ await this.indexBuild;
1478
+ continue;
1479
+ }
1480
+ const generation = this.indexGeneration;
1481
+ const build = (async () => {
1482
+ const diskToken = await readContentIndexGeneration(this.root);
1483
+ const fresh = new ContentIdIndex(this.root);
1484
+ await fresh.buildFromFilenames("content");
1485
+ return { diskToken, fresh };
1486
+ })();
1487
+ this.indexBuild = build;
1488
+ let result;
1489
+ try {
1490
+ result = await build;
1491
+ } finally {
1492
+ this.indexBuild = null;
1493
+ }
1494
+ this._idIndex = result.fresh;
1495
+ this.loadedIndexGeneration = generation;
1496
+ this.loadedDiskGeneration = result.diskToken;
1497
+ this.lastDiskProbeMs = Date.now();
1240
1498
  }
1241
1499
  return this._idIndex;
1242
1500
  }
1501
+ /**
1502
+ * Throttle for the on-disk marker probe. Stamps the clock synchronously
1503
+ * before the caller's readFile so concurrent idIndex() callers don't
1504
+ * double-probe (and double-rebuild) on the same token change.
1505
+ */
1506
+ shouldProbeDiskGeneration() {
1507
+ const now = Date.now();
1508
+ if (now - this.lastDiskProbeMs < this.indexFreshnessIntervalMs)
1509
+ return false;
1510
+ this.lastDiskProbeMs = now;
1511
+ return true;
1512
+ }
1513
+ /**
1514
+ * After one of this store's own successful mutations: publish the change to
1515
+ * other processes (bump the on-disk marker) and adopt the written token,
1516
+ * since the in-memory index was already updated incrementally — avoiding a
1517
+ * pointless self-rescan on the next probe. Adoption is skipped unless the
1518
+ * index is quiescent and `updatedIndex` is still the live instance: if a
1519
+ * rebuild raced with the mutation, its scan may predate our file change, so
1520
+ * we leave the recorded token older and let the next probe observe our bump
1521
+ * and trigger the healing rebuild.
1522
+ */
1523
+ async recordOwnMutation(updatedIndex) {
1524
+ const token = await bumpContentIndexGeneration(this.root);
1525
+ if (token !== null && this.indexBuild === null && this.loadedIndexGeneration === this.indexGeneration && this._idIndex === updatedIndex) {
1526
+ this.loadedDiskGeneration = token;
1527
+ }
1528
+ }
1529
+ /**
1530
+ * Backstop for the residual staleness windows of the cross-process marker
1531
+ * (NFS attribute caching, probe throttle, self-adoption — see
1532
+ * content-index-generation.ts): force one rebuild in response to a
1533
+ * suspicious lookup (an ID miss, or an index hit whose file is gone), but
1534
+ * throttle how often a caller can force one. Time-boxed so genuinely
1535
+ * dangling IDs cost at most one rescan per window. Returns true if a
1536
+ * refresh was performed by THIS call; callers that lose the throttle race
1537
+ * still benefit — idIndex() dedupes concurrent builds, so a caller that won
1538
+ * the race rebuilds the index for everyone, and callers should still retry
1539
+ * their lookup against the live index regardless of this return value.
1540
+ */
1541
+ async refreshIndexForSuspiciousLookup() {
1542
+ const now = Date.now();
1543
+ if (now - this.lastForcedRefreshMs < FORCED_REFRESH_MIN_INTERVAL_MS)
1544
+ return false;
1545
+ this.lastForcedRefreshMs = now;
1546
+ this.invalidateIndex();
1547
+ await this.idIndex();
1548
+ return true;
1549
+ }
1550
+ /**
1551
+ * Find the file in `dir` whose filename embeds `id`.
1552
+ * Returns its root-relative path, or null if no such file (or no such dir).
1553
+ */
1554
+ async findEntryPathById(dir, id) {
1555
+ let entries;
1556
+ try {
1557
+ entries = await fs6.readdir(dir, { withFileTypes: true });
1558
+ } catch (err) {
1559
+ if (isNodeError(err) && err.code === "ENOENT")
1560
+ return null;
1561
+ throw err;
1562
+ }
1563
+ for (const entry of entries) {
1564
+ if (entry.isDirectory())
1565
+ continue;
1566
+ if (extractIdFromFilename(entry.name) === id) {
1567
+ return path7.relative(this.root, path7.join(dir, entry.name));
1568
+ }
1569
+ }
1570
+ return null;
1571
+ }
1243
1572
  /**
1244
1573
  * Get all schema items for iteration.
1245
1574
  * Used internally by ReferenceResolver for path matching.
@@ -1247,11 +1576,11 @@ var ContentStore = class {
1247
1576
  getSchemaItems() {
1248
1577
  return this.schemaIndex.values();
1249
1578
  }
1250
- assertSchemaItem(path15) {
1251
- const normalized = normalizeFilesystemPath(path15);
1579
+ assertSchemaItem(path18) {
1580
+ const normalized = normalizeFilesystemPath(path18);
1252
1581
  const item = this.schemaIndex.get(normalized);
1253
1582
  if (!item) {
1254
- throw new ContentStoreError(`Unknown schema item: ${path15}`, "NO_SCHEMA_ITEM");
1583
+ throw new ContentStoreError(`Unknown schema item: ${path18}`, "NO_SCHEMA_ITEM");
1255
1584
  }
1256
1585
  return item;
1257
1586
  }
@@ -1278,7 +1607,7 @@ var ContentStore = class {
1278
1607
  * @param options.entryTypeName - For collections with multiple entry types, specify which one to use. Defaults to the default entry type.
1279
1608
  */
1280
1609
  async buildPaths(schemaItem, slug, options = {}) {
1281
- const rootWithSep = this.root.endsWith(path5.sep) ? this.root : `${this.root}${path5.sep}`;
1610
+ const rootWithSep = this.root.endsWith(path7.sep) ? this.root : `${this.root}${path7.sep}`;
1282
1611
  if (schemaItem.type === "entry-type") {
1283
1612
  const parentPath = schemaItem.parentPath || "";
1284
1613
  const parentCollection = this.schemaIndex.get(parentPath);
@@ -1311,7 +1640,7 @@ var ContentStore = class {
1311
1640
  const entryTypeName = entryTypeConfig?.name || "entry";
1312
1641
  let collectionRoot = await resolveCollectionPath(this.root, schemaItem.logicalPath);
1313
1642
  if (!collectionRoot) {
1314
- collectionRoot = path5.resolve(this.root, schemaItem.logicalPath);
1643
+ collectionRoot = path7.resolve(this.root, schemaItem.logicalPath);
1315
1644
  }
1316
1645
  if (!collectionRoot.startsWith(rootWithSep)) {
1317
1646
  throw new ContentStoreError("Path traversal detected", "VALIDATION");
@@ -1320,7 +1649,7 @@ var ContentStore = class {
1320
1649
  let existingFilename;
1321
1650
  let existingEntryType;
1322
1651
  if (!id) {
1323
- const entries = await fs4.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
1652
+ const entries = await fs6.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
1324
1653
  const existingFile = entries.find((entry) => {
1325
1654
  if (entry.isDirectory())
1326
1655
  return false;
@@ -1344,14 +1673,14 @@ var ContentStore = class {
1344
1673
  }
1345
1674
  filename = `${finalEntryTypeName}.${safeSlug}.${id}${ext}`;
1346
1675
  }
1347
- const resolved = path5.resolve(collectionRoot, filename);
1348
- const collectionRootWithSep = collectionRoot.endsWith(path5.sep) ? collectionRoot : `${collectionRoot}${path5.sep}`;
1676
+ const resolved = path7.resolve(collectionRoot, filename);
1677
+ const collectionRootWithSep = collectionRoot.endsWith(path7.sep) ? collectionRoot : `${collectionRoot}${path7.sep}`;
1349
1678
  if (!resolved.startsWith(collectionRootWithSep)) {
1350
1679
  throw new ContentStoreError("Path traversal detected", "VALIDATION");
1351
1680
  }
1352
1681
  return {
1353
1682
  absolutePath: resolved,
1354
- relativePath: path5.relative(this.root, resolved),
1683
+ relativePath: path7.relative(this.root, resolved),
1355
1684
  id,
1356
1685
  entryTypeName: finalEntryTypeName
1357
1686
  };
@@ -1386,8 +1715,8 @@ var ContentStore = class {
1386
1715
  async read(collectionPath, slug = "", options = {}) {
1387
1716
  const schemaItem = this.assertSchemaItem(collectionPath);
1388
1717
  const { absolutePath, relativePath, entryTypeName: resolvedEntryTypeName } = await this.buildPaths(schemaItem, slug);
1389
- const stat = await fs4.stat(absolutePath);
1390
- const raw = await fs4.readFile(absolutePath, "utf8");
1718
+ const stat = await fs6.stat(absolutePath);
1719
+ const raw = await fs6.readFile(absolutePath, "utf8");
1391
1720
  let doc;
1392
1721
  let format;
1393
1722
  let fields;
@@ -1445,7 +1774,7 @@ var ContentStore = class {
1445
1774
  return doc;
1446
1775
  }
1447
1776
  async write(collectionPath, slug = "", input, entryTypeName, existingId) {
1448
- const idIndex = await this.idIndex();
1777
+ await this.idIndex();
1449
1778
  const schemaItem = this.assertSchemaItem(collectionPath);
1450
1779
  let expectedFormat;
1451
1780
  let fields = [];
@@ -1473,10 +1802,10 @@ var ContentStore = class {
1473
1802
  existingId
1474
1803
  });
1475
1804
  return withLock(absolutePath, async () => {
1476
- await fs4.mkdir(path5.dirname(absolutePath), { recursive: true });
1805
+ await fs6.mkdir(path7.dirname(absolutePath), { recursive: true });
1477
1806
  if (input.expectedVersion !== void 0) {
1478
1807
  try {
1479
- const existing = await fs4.stat(absolutePath);
1808
+ const existing = await fs6.stat(absolutePath);
1480
1809
  if (existing.mtimeMs !== input.expectedVersion) {
1481
1810
  throw new ContentConflictError();
1482
1811
  }
@@ -1489,6 +1818,13 @@ var ContentStore = class {
1489
1818
  }
1490
1819
  }
1491
1820
  }
1821
+ if (existingId && !await filePathExists(absolutePath)) {
1822
+ const actualRelPath = await this.findEntryPathById(path7.dirname(absolutePath), existingId);
1823
+ const indexedRelPath = this._idIndex.findById(existingId)?.relativePath ?? null;
1824
+ if (actualRelPath !== null && actualRelPath !== indexedRelPath) {
1825
+ throw new ContentConflictError();
1826
+ }
1827
+ }
1492
1828
  let content;
1493
1829
  if (input.format === "json") {
1494
1830
  content = `${JSON.stringify(input.data ?? {}, null, 2)}
@@ -1499,19 +1835,17 @@ var ContentStore = class {
1499
1835
  content = matter.stringify(input.body, input.data ?? {});
1500
1836
  }
1501
1837
  await atomicWriteFile(absolutePath, content);
1838
+ const liveIndex = this._idIndex;
1839
+ let staleOldAbsPath = null;
1502
1840
  if (id) {
1503
- const existing = idIndex.findById(id);
1841
+ const existing = liveIndex.findById(id);
1504
1842
  if (existing) {
1505
1843
  if (existing.relativePath !== relativePath) {
1506
- const oldAbsPath = path5.join(this.root, existing.relativePath);
1507
- await fs4.unlink(oldAbsPath).catch((err) => {
1508
- if (!isNodeError(err) || err.code !== "ENOENT")
1509
- throw err;
1510
- });
1511
- idIndex.updatePath(existing.id, relativePath);
1844
+ staleOldAbsPath = path7.join(this.root, existing.relativePath);
1845
+ liveIndex.updatePath(existing.id, relativePath);
1512
1846
  }
1513
1847
  } else {
1514
- idIndex.add({
1848
+ liveIndex.add({
1515
1849
  type: "entry",
1516
1850
  relativePath,
1517
1851
  collection: collectionPath,
@@ -1519,7 +1853,14 @@ var ContentStore = class {
1519
1853
  });
1520
1854
  }
1521
1855
  }
1522
- const afterStat = await fs4.stat(absolutePath);
1856
+ if (staleOldAbsPath) {
1857
+ await fs6.unlink(staleOldAbsPath).catch((err) => {
1858
+ if (!isNodeError(err) || err.code !== "ENOENT")
1859
+ throw err;
1860
+ });
1861
+ }
1862
+ await this.recordOwnMutation(liveIndex);
1863
+ const afterStat = await fs6.stat(absolutePath);
1523
1864
  const base = {
1524
1865
  collection: schemaItem.logicalPath,
1525
1866
  collectionName: schemaItem.name,
@@ -1545,13 +1886,34 @@ var ContentStore = class {
1545
1886
  /**
1546
1887
  * Read an entry by its ID (UUID).
1547
1888
  * Returns null if the ID doesn't exist or points to a collection.
1889
+ *
1890
+ * Suspicious results (ID missing from the index, or an index hit whose file
1891
+ * is gone) trigger one forced index refresh and a retry — self-healing for
1892
+ * mutations by other processes inside the marker's residual windows.
1548
1893
  */
1549
1894
  async readById(id) {
1895
+ const first = await this.readByIdOnce(id);
1896
+ if (first !== STALE_LOOKUP)
1897
+ return first;
1898
+ if (!await this.refreshIndexForSuspiciousLookup())
1899
+ return null;
1900
+ const second = await this.readByIdOnce(id);
1901
+ return second === STALE_LOOKUP ? null : second;
1902
+ }
1903
+ async readByIdOnce(id) {
1550
1904
  const idIndex = await this.idIndex();
1551
1905
  const location = idIndex.findById(id);
1552
- if (!location || location.type !== "entry")
1906
+ if (!location)
1907
+ return STALE_LOOKUP;
1908
+ if (location.type !== "entry")
1553
1909
  return null;
1554
- return this.read(location.collection, location.slug);
1910
+ try {
1911
+ return await this.read(location.collection, location.slug);
1912
+ } catch (err) {
1913
+ if (isNodeError(err) && err.code === "ENOENT")
1914
+ return STALE_LOOKUP;
1915
+ throw err;
1916
+ }
1555
1917
  }
1556
1918
  /**
1557
1919
  * Get the ID for an entry given its collection and slug.
@@ -1562,19 +1924,72 @@ var ContentStore = class {
1562
1924
  const { relativePath } = await this.buildPaths(this.assertCollection(collectionPath), slug);
1563
1925
  return idIndex.findByPath(relativePath);
1564
1926
  }
1927
+ /**
1928
+ * Check whether a document already exists on disk for this collection+slug.
1929
+ * Used by the write boundary to distinguish creates from edits (create
1930
+ * scaffolds and maxItems enforcement).
1931
+ */
1932
+ async documentExists(collectionPath, slug = "") {
1933
+ const schemaItem = this.assertSchemaItem(collectionPath);
1934
+ const { absolutePath } = await this.buildPaths(schemaItem, slug);
1935
+ try {
1936
+ await fs6.access(absolutePath);
1937
+ return true;
1938
+ } catch (err) {
1939
+ if (isNodeError(err) && err.code === "ENOENT")
1940
+ return false;
1941
+ throw err;
1942
+ }
1943
+ }
1944
+ /**
1945
+ * Resolve the actual on-disk entry type of an existing collection entry, by
1946
+ * inspecting its filename directly. Entry filenames embed their type
1947
+ * (`{type}.{slug}.{id}.{ext}`) and it is immutable after creation — see the
1948
+ * existing-file lookup in buildPaths(), which this mirrors. Returns
1949
+ * undefined if no entry exists yet at this slug.
1950
+ *
1951
+ * Used at the API write boundary (api/content.ts) to validate an existing
1952
+ * entry's payload against ITS real schema rather than a caller-supplied (or
1953
+ * omitted/spoofed) `entryType` param — ContentStore.write() preserves the
1954
+ * on-disk type regardless of what's requested, so validation must agree
1955
+ * with what will actually be written (post-review M2).
1956
+ *
1957
+ * Cheap: one buildPaths() resolution plus a stat, no file content is read —
1958
+ * same cost class as documentExists().
1959
+ */
1960
+ async getExistingEntryType(collectionPath, slug = "") {
1961
+ const schemaItem = this.assertCollection(collectionPath);
1962
+ const { absolutePath, entryTypeName } = await this.buildPaths(schemaItem, slug);
1963
+ return await filePathExists(absolutePath) ? entryTypeName : void 0;
1964
+ }
1965
+ /**
1966
+ * Count existing entries of a given entry type in a collection, by filename
1967
+ * (entry filenames embed their type: `{type}.{slug}.{id}.{ext}`). Used to
1968
+ * enforce `EntryTypeConfig.maxItems` server-side at the create boundary.
1969
+ */
1970
+ async countEntriesOfType(collectionPath, entryTypeName) {
1971
+ this.assertCollection(collectionPath);
1972
+ const collectionRoot = await resolveCollectionPath(this.root, collectionPath);
1973
+ if (!collectionRoot)
1974
+ return 0;
1975
+ const entries = await fs6.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
1976
+ return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
1977
+ }
1565
1978
  /**
1566
1979
  * Delete an entry and remove it from the index.
1567
1980
  */
1568
1981
  async delete(collectionPath, slug) {
1569
- const idIndex = await this.idIndex();
1982
+ await this.idIndex();
1570
1983
  const collection = this.assertCollection(collectionPath);
1571
1984
  const { absolutePath, relativePath } = await this.buildPaths(collection, slug);
1572
1985
  return withLock(absolutePath, async () => {
1573
- const id = idIndex.findByPath(relativePath);
1574
- await fs4.unlink(absolutePath);
1986
+ await fs6.unlink(absolutePath);
1987
+ const liveIndex = this._idIndex;
1988
+ const id = liveIndex.findByPath(relativePath);
1575
1989
  if (id) {
1576
- idIndex.remove(id);
1990
+ liveIndex.remove(id);
1577
1991
  }
1992
+ await this.recordOwnMutation(liveIndex);
1578
1993
  });
1579
1994
  }
1580
1995
  /**
@@ -1588,7 +2003,7 @@ var ContentStore = class {
1588
2003
  * @throws ContentStoreError if entry doesn't exist, new slug conflicts, or validation fails
1589
2004
  */
1590
2005
  async renameEntry(collectionPath, currentSlug, newSlug) {
1591
- const idIndex = await this.idIndex();
2006
+ await this.idIndex();
1592
2007
  const collection = this.assertCollection(collectionPath);
1593
2008
  validateSlug(newSlug);
1594
2009
  const safeNewSlug = newSlug.replace(/^\/+/, "");
@@ -1601,11 +2016,11 @@ var ContentStore = class {
1601
2016
  const { absolutePath: currentPath, relativePath: currentRelPath } = await this.buildPaths(collection, currentSlug);
1602
2017
  return withLock(currentPath, async () => {
1603
2018
  try {
1604
- await fs4.access(currentPath);
2019
+ await fs6.access(currentPath);
1605
2020
  } catch {
1606
2021
  throw new ContentStoreError(`Entry not found: ${currentSlug}`, "NOT_FOUND");
1607
2022
  }
1608
- const currentFilename = path5.basename(currentPath);
2023
+ const currentFilename = path7.basename(currentPath);
1609
2024
  const parts = currentFilename.split(".");
1610
2025
  if (parts.length < 4) {
1611
2026
  throw new ContentStoreError(`Invalid entry filename format: ${currentFilename}`, "VALIDATION");
@@ -1614,10 +2029,10 @@ var ContentStore = class {
1614
2029
  const contentId = parts[parts.length - 2];
1615
2030
  const ext = `.${parts[parts.length - 1]}`;
1616
2031
  const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
1617
- const parentDir = path5.dirname(currentPath);
1618
- const newPath = path5.join(parentDir, newFilename);
2032
+ const parentDir = path7.dirname(currentPath);
2033
+ const newPath = path7.join(parentDir, newFilename);
1619
2034
  try {
1620
- const entries = await fs4.readdir(parentDir, { withFileTypes: true });
2035
+ const entries = await fs6.readdir(parentDir, { withFileTypes: true });
1621
2036
  for (const entry of entries) {
1622
2037
  if (entry.isDirectory())
1623
2038
  continue;
@@ -1631,19 +2046,21 @@ var ContentStore = class {
1631
2046
  throw err;
1632
2047
  }
1633
2048
  try {
1634
- await fs4.link(currentPath, newPath);
2049
+ await fs6.link(currentPath, newPath);
1635
2050
  } catch (err) {
1636
2051
  if (isNodeError(err) && err.code === "EEXIST") {
1637
2052
  throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
1638
2053
  }
1639
2054
  throw err;
1640
2055
  }
1641
- await fs4.unlink(currentPath);
1642
- const newRelativePath = path5.relative(this.root, newPath);
1643
- const entryId = idIndex.findByPath(currentRelPath);
2056
+ await fs6.unlink(currentPath);
2057
+ const newRelativePath = path7.relative(this.root, newPath);
2058
+ const liveIndex = this._idIndex;
2059
+ const entryId = liveIndex.findByPath(currentRelPath);
1644
2060
  if (entryId) {
1645
- idIndex.updatePath(entryId, newRelativePath);
2061
+ liveIndex.updatePath(entryId, newRelativePath);
1646
2062
  }
2063
+ await this.recordOwnMutation(liveIndex);
1647
2064
  return { newPath: `${collectionPath}/${safeNewSlug}` };
1648
2065
  });
1649
2066
  }
@@ -1737,11 +2154,32 @@ var ContentStore = class {
1737
2154
  * Resolve a single reference ID to full entry data.
1738
2155
  * Returns null if the reference is invalid or missing.
1739
2156
  * Includes id, slug, and collection fields for debugging.
2157
+ *
2158
+ * Suspicious results (ID missing from the index, or an index hit whose file
2159
+ * is gone) trigger one forced index refresh and a retry — self-healing for
2160
+ * mutations by other processes inside the marker's residual windows. The
2161
+ * forced refresh itself is throttled (see refreshIndexForSuspiciousLookup),
2162
+ * but the retry against the live index always runs regardless of whether
2163
+ * this call won the throttle race: when a `list: true` reference array is
2164
+ * resolved via Promise.all, every miss shares the same stale snapshot, and
2165
+ * idIndex() dedupes concurrent builds — so a sibling call that wins the
2166
+ * throttle and rebuilds heals every other miss in the same batch, not just
2167
+ * the first.
1740
2168
  */
1741
2169
  async resolveSingleReference(id, idIndex) {
2170
+ const first = await this.resolveSingleReferenceOnce(id, idIndex);
2171
+ if (first !== STALE_LOOKUP)
2172
+ return first;
2173
+ await this.refreshIndexForSuspiciousLookup();
2174
+ const second = await this.resolveSingleReferenceOnce(id, await this.idIndex());
2175
+ return second === STALE_LOOKUP ? null : second;
2176
+ }
2177
+ async resolveSingleReferenceOnce(id, idIndex) {
1742
2178
  try {
1743
2179
  const location = idIndex.findById(id);
1744
- if (!location || location.type !== "entry" || !location.collection || !location.slug) {
2180
+ if (!location)
2181
+ return STALE_LOOKUP;
2182
+ if (location.type !== "entry" || !location.collection || !location.slug) {
1745
2183
  return null;
1746
2184
  }
1747
2185
  const doc = await this.read(location.collection, location.slug, {
@@ -1754,6 +2192,8 @@ var ContentStore = class {
1754
2192
  ...doc.data
1755
2193
  };
1756
2194
  } catch (error) {
2195
+ if (isNodeError(error) && error.code === "ENOENT")
2196
+ return STALE_LOOKUP;
1757
2197
  console.error(`Failed to resolve reference ${id}:`, error);
1758
2198
  return null;
1759
2199
  }
@@ -1761,11 +2201,11 @@ var ContentStore = class {
1761
2201
  };
1762
2202
 
1763
2203
  // dist/branch-schema-cache.js
1764
- import fs6 from "node:fs/promises";
1765
- import path6 from "node:path";
2204
+ import fs8 from "node:fs/promises";
2205
+ import path8 from "node:path";
1766
2206
 
1767
2207
  // dist/schema/meta-loader.js
1768
- import { promises as fs5 } from "fs";
2208
+ import { promises as fs7 } from "fs";
1769
2209
  import { join as join2 } from "pathe";
1770
2210
  import { z as z6 } from "zod";
1771
2211
  import chokidar from "chokidar";
@@ -1801,7 +2241,7 @@ function stripEmbeddedIdFromName(name) {
1801
2241
  async function scanForCollectionMeta(baseDir, relativePath = "") {
1802
2242
  const collections = [];
1803
2243
  try {
1804
- const entries = await fs5.readdir(baseDir, { withFileTypes: true });
2244
+ const entries = await fs7.readdir(baseDir, { withFileTypes: true });
1805
2245
  for (const entry of entries) {
1806
2246
  if (!entry.isDirectory())
1807
2247
  continue;
@@ -1812,8 +2252,8 @@ async function scanForCollectionMeta(baseDir, relativePath = "") {
1812
2252
  const absolutePath = join2(baseDir, folderName);
1813
2253
  const metaPath = join2(absolutePath, ".collection.json");
1814
2254
  try {
1815
- await fs5.access(metaPath);
1816
- const content = await fs5.readFile(metaPath, "utf-8");
2255
+ await fs7.access(metaPath);
2256
+ const content = await fs7.readFile(metaPath, "utf-8");
1817
2257
  const parsed = JSON.parse(content);
1818
2258
  const meta = collectionMetaSchema.parse(parsed);
1819
2259
  collections.push({
@@ -1845,7 +2285,7 @@ async function loadCollectionMetaFiles(contentRoot) {
1845
2285
  let root = null;
1846
2286
  const rootMetaPath = join2(contentRoot, ".collection.json");
1847
2287
  try {
1848
- await fs5.access(rootMetaPath);
2288
+ await fs7.access(rootMetaPath);
1849
2289
  } catch (err) {
1850
2290
  if (err.code === "ENOENT") {
1851
2291
  } else {
@@ -1853,7 +2293,7 @@ async function loadCollectionMetaFiles(contentRoot) {
1853
2293
  }
1854
2294
  }
1855
2295
  try {
1856
- const content = await fs5.readFile(rootMetaPath, "utf-8");
2296
+ const content = await fs7.readFile(rootMetaPath, "utf-8");
1857
2297
  const parsed = JSON.parse(content);
1858
2298
  root = rootCollectionMetaSchema.parse(parsed);
1859
2299
  } catch (err) {
@@ -1971,16 +2411,16 @@ var MTIME_CHECK_DEBOUNCE_MS = 1e3;
1971
2411
  async function isStaleByMtime(dir, cachedAt) {
1972
2412
  let entries;
1973
2413
  try {
1974
- entries = await fs6.readdir(dir, { recursive: true, encoding: "utf-8" });
2414
+ entries = await fs8.readdir(dir, { recursive: true, encoding: "utf-8" });
1975
2415
  } catch {
1976
2416
  return true;
1977
2417
  }
1978
2418
  for (const entry of entries) {
1979
2419
  if (!entry.endsWith(".collection.json"))
1980
2420
  continue;
1981
- const full = path6.join(dir, entry);
2421
+ const full = path8.join(dir, entry);
1982
2422
  try {
1983
- const stat = await fs6.stat(full);
2423
+ const stat = await fs8.stat(full);
1984
2424
  if (stat.mtimeMs > cachedAt.getTime())
1985
2425
  return true;
1986
2426
  } catch {
@@ -2004,7 +2444,7 @@ var BranchSchemaCache = class {
2004
2444
  * the project root, regardless of which entrypoint produced the cwd branchRoot.
2005
2445
  */
2006
2446
  skipDiskCache(branchRoot) {
2007
- return isBuildMode() || path6.resolve(branchRoot) === path6.resolve(process.cwd());
2447
+ return isBuildMode() || path8.resolve(branchRoot) === path8.resolve(process.cwd());
2008
2448
  }
2009
2449
  /**
2010
2450
  * Get schema for a branch (loads from cache or resolves fresh).
@@ -2021,17 +2461,17 @@ var BranchSchemaCache = class {
2021
2461
  * Load schema from cache or resolve fresh if cache is missing or stale.
2022
2462
  */
2023
2463
  async loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName) {
2024
- const contentRoot = path6.join(branchRoot, contentRootName);
2464
+ const contentRoot = path8.join(branchRoot, contentRootName);
2025
2465
  const skipDiskCache = this.skipDiskCache(branchRoot);
2026
2466
  if (!skipDiskCache) {
2027
- const cacheDir = path6.join(branchRoot, ".canopy-meta");
2028
- const cachePath = path6.join(cacheDir, "schema-cache.json");
2029
- const stalePath = path6.join(cacheDir, "schema-cache.stale");
2467
+ const cacheDir = path8.join(branchRoot, ".canopy-meta");
2468
+ const cachePath = path8.join(cacheDir, "schema-cache.json");
2469
+ const stalePath = path8.join(cacheDir, "schema-cache.stale");
2030
2470
  let cacheData = null;
2031
2471
  try {
2032
- const staleExists = await fs6.access(stalePath).then(() => true).catch(() => false);
2472
+ const staleExists = await fs8.access(stalePath).then(() => true).catch(() => false);
2033
2473
  if (!staleExists) {
2034
- const cacheContent = await fs6.readFile(cachePath, "utf-8");
2474
+ const cacheContent = await fs8.readFile(cachePath, "utf-8");
2035
2475
  cacheData = JSON.parse(cacheContent);
2036
2476
  }
2037
2477
  } catch {
@@ -2056,21 +2496,21 @@ var BranchSchemaCache = class {
2056
2496
  }
2057
2497
  const flatSchema = flattenSchema(result.schema, contentRootName);
2058
2498
  if (!skipDiskCache) {
2059
- const cacheDir = path6.join(branchRoot, ".canopy-meta");
2060
- const cachePath = path6.join(cacheDir, "schema-cache.json");
2061
- const stalePath = path6.join(cacheDir, "schema-cache.stale");
2062
- await fs6.mkdir(cacheDir, { recursive: true });
2499
+ const cacheDir = path8.join(branchRoot, ".canopy-meta");
2500
+ const cachePath = path8.join(cacheDir, "schema-cache.json");
2501
+ const stalePath = path8.join(cacheDir, "schema-cache.stale");
2502
+ await fs8.mkdir(cacheDir, { recursive: true });
2063
2503
  const newCache = {
2064
2504
  version: SCHEMA_CACHE_VERSION,
2065
2505
  schema: result.schema,
2066
2506
  flatSchema,
2067
2507
  cachedAt: (/* @__PURE__ */ new Date()).toISOString()
2068
2508
  };
2069
- const tmpPath = path6.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
2070
- await fs6.writeFile(tmpPath, JSON.stringify(newCache, null, 2), "utf-8");
2071
- await fs6.rename(tmpPath, cachePath);
2509
+ const tmpPath = path8.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
2510
+ await fs8.writeFile(tmpPath, JSON.stringify(newCache, null, 2), "utf-8");
2511
+ await fs8.rename(tmpPath, cachePath);
2072
2512
  try {
2073
- await fs6.unlink(stalePath);
2513
+ await fs8.unlink(stalePath);
2074
2514
  } catch {
2075
2515
  }
2076
2516
  }
@@ -2084,16 +2524,16 @@ var BranchSchemaCache = class {
2084
2524
  async invalidate(branchRoot) {
2085
2525
  if (this.skipDiskCache(branchRoot))
2086
2526
  return;
2087
- const cacheDir = path6.join(branchRoot, ".canopy-meta");
2088
- const stalePath = path6.join(cacheDir, "schema-cache.stale");
2089
- await fs6.mkdir(cacheDir, { recursive: true });
2090
- await fs6.writeFile(stalePath, "", "utf-8");
2527
+ const cacheDir = path8.join(branchRoot, ".canopy-meta");
2528
+ const stalePath = path8.join(cacheDir, "schema-cache.stale");
2529
+ await fs8.mkdir(cacheDir, { recursive: true });
2530
+ await fs8.writeFile(stalePath, "", "utf-8");
2091
2531
  }
2092
2532
  };
2093
2533
 
2094
2534
  // dist/ai/generate.js
2095
- import path7 from "node:path";
2096
- import fs7 from "node:fs/promises";
2535
+ import path9 from "node:path";
2536
+ import fs9 from "node:fs/promises";
2097
2537
  import { minimatch } from "minimatch";
2098
2538
 
2099
2539
  // dist/ai/strip-mdx.js
@@ -2599,97 +3039,6 @@ function yamlValue(value) {
2599
3039
  return value;
2600
3040
  }
2601
3041
 
2602
- // dist/utils/debug.js
2603
- var LOG_LEVELS = {
2604
- DEBUG: 0,
2605
- INFO: 1,
2606
- WARN: 2,
2607
- ERROR: 3
2608
- };
2609
- var DebugLogger = class {
2610
- constructor(options = {}) {
2611
- this.timers = /* @__PURE__ */ new Map();
2612
- this.options = options;
2613
- }
2614
- shouldLog(level) {
2615
- const enabled = this.options.enabled ?? process.env.CANOPYCMS_DEBUG === "true";
2616
- if (!enabled)
2617
- return false;
2618
- const minLevel = this.options.minLevel ?? "DEBUG";
2619
- return LOG_LEVELS[level] >= LOG_LEVELS[minLevel];
2620
- }
2621
- formatMessage(level, category, message) {
2622
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2623
- const prefix = this.options.prefix ?? "CanopyCMS";
2624
- return `[${timestamp}] [${prefix}:${category}] [${level}] ${message}`;
2625
- }
2626
- debug(category, message, data) {
2627
- if (this.shouldLog("DEBUG")) {
2628
- console.log(this.formatMessage("DEBUG", category, message), data ?? "");
2629
- }
2630
- }
2631
- info(category, message, data) {
2632
- if (this.shouldLog("INFO")) {
2633
- console.log(this.formatMessage("INFO", category, message), data ?? "");
2634
- }
2635
- }
2636
- warn(category, message, data) {
2637
- if (this.shouldLog("WARN")) {
2638
- console.warn(this.formatMessage("WARN", category, message), data ?? "");
2639
- }
2640
- }
2641
- error(category, message, data) {
2642
- const msg = this.formatMessage("ERROR", category, message);
2643
- if (this.shouldLog("ERROR")) {
2644
- console.error(msg, data ?? "");
2645
- }
2646
- const throwOnError = this.options.throwOnError ?? false;
2647
- if (throwOnError) {
2648
- const errorMsg = data ? `${message}: ${JSON.stringify(data)}` : message;
2649
- throw new Error(errorMsg);
2650
- }
2651
- }
2652
- /**
2653
- * Start timing an operation
2654
- */
2655
- time(label) {
2656
- this.timers.set(label, Date.now());
2657
- }
2658
- /**
2659
- * End timing an operation and log the duration
2660
- */
2661
- timeEnd(category, label) {
2662
- const start = this.timers.get(label);
2663
- if (start === void 0) {
2664
- this.warn(category, `Timer '${label}' does not exist`);
2665
- return;
2666
- }
2667
- const duration = Date.now() - start;
2668
- this.timers.delete(label);
2669
- this.debug(category, `${label} completed`, { durationMs: duration });
2670
- return duration;
2671
- }
2672
- /**
2673
- * Wrap an async function with automatic timing
2674
- */
2675
- async timed(category, label, fn) {
2676
- this.time(label);
2677
- try {
2678
- return await fn();
2679
- } finally {
2680
- this.timeEnd(category, label);
2681
- }
2682
- }
2683
- };
2684
- function createDebugLogger(options) {
2685
- return new DebugLogger(options);
2686
- }
2687
- var testLogger = createDebugLogger({
2688
- enabled: process.env.E2E_DEBUG === "true",
2689
- prefix: "E2E",
2690
- throwOnError: false
2691
- });
2692
-
2693
3042
  // dist/utils/entry-url.js
2694
3043
  init_normalize();
2695
3044
  function computeEntryUrl(collection, slug, contentRoot) {
@@ -2704,12 +3053,12 @@ function computeEntryUrl(collection, slug, contentRoot) {
2704
3053
  if (slug && slug !== "index") {
2705
3054
  segments.push(slug);
2706
3055
  }
2707
- const path15 = segments.length > 0 ? `/${segments.join("/")}` : "/";
2708
- return path15.toLowerCase();
3056
+ const path18 = segments.length > 0 ? `/${segments.join("/")}` : "/";
3057
+ return path18.toLowerCase();
2709
3058
  }
2710
3059
 
2711
3060
  // dist/entry-link-resolver.js
2712
- var log = createDebugLogger({ prefix: "EntryLinks" });
3061
+ var log2 = createDebugLogger({ prefix: "EntryLinks" });
2713
3062
  var BASE58_CHAR = "[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]";
2714
3063
  var ENTRY_LINK_PATTERN = new RegExp(`entry:(${BASE58_CHAR}{12})(#[^\\s)>"']*)?`, "g");
2715
3064
  function resolveEntryUrl(location, contentRoot) {
@@ -2723,7 +3072,7 @@ function resolveEntryLinksInText(text, idIndex, contentRoot, customResolver) {
2723
3072
  return part.text.replace(ENTRY_LINK_PATTERN, (_match, id, anchor) => {
2724
3073
  const location = idIndex.findById(id);
2725
3074
  if (!location || location.type !== "entry" || !location.collection || !location.slug) {
2726
- log.warn("resolve", `Entry link target not found: entry:${id}`);
3075
+ log2.warn("resolve", `Entry link target not found: entry:${id}`);
2727
3076
  return anchor ?? "#";
2728
3077
  }
2729
3078
  let url;
@@ -2880,7 +3229,7 @@ async function processCollection(store, collection, flatSchema, contentRoot, con
2880
3229
  const listed = await store.getCollectionEntryPaths(collection.logicalPath);
2881
3230
  const directEntries = listed.filter((e) => e.collection === collection.logicalPath);
2882
3231
  for (const listEntry of directEntries) {
2883
- const entryTypeName = extractEntryTypeFromFilename(path7.basename(listEntry.relativePath));
3232
+ const entryTypeName = extractEntryTypeFromFilename(path9.basename(listEntry.relativePath));
2884
3233
  if (!entryTypeName)
2885
3234
  continue;
2886
3235
  if (config?.exclude?.entryTypes?.includes(entryTypeName))
@@ -2898,7 +3247,7 @@ async function processCollection(store, collection, flatSchema, contentRoot, con
2898
3247
  }
2899
3248
  if (config?.exclude?.where?.(aiEntry))
2900
3249
  continue;
2901
- await runEntryTransform(aiEntry, doc.absolutePath, extractIdFromFilename(path7.basename(listEntry.relativePath)), config);
3250
+ await runEntryTransform(aiEntry, doc.absolutePath, extractIdFromFilename(path9.basename(listEntry.relativePath)), config);
2902
3251
  entries.push(aiEntry);
2903
3252
  const entryFilePath = `${cleanPath}/${listEntry.slug}.md`;
2904
3253
  const entryMarkdown = entryToMarkdown(aiEntry, config);
@@ -2949,7 +3298,7 @@ async function processRootEntries(store, rootCollection, contentRoot, config, id
2949
3298
  const listed = await store.getCollectionEntryPaths(rootCollection.logicalPath);
2950
3299
  const directEntries = listed.filter((e) => e.collection === rootCollection.logicalPath);
2951
3300
  for (const listEntry of directEntries) {
2952
- const entryTypeName = extractEntryTypeFromFilename(path7.basename(listEntry.relativePath));
3301
+ const entryTypeName = extractEntryTypeFromFilename(path9.basename(listEntry.relativePath));
2953
3302
  if (!entryTypeName)
2954
3303
  continue;
2955
3304
  if (config?.exclude?.entryTypes?.includes(entryTypeName))
@@ -2967,7 +3316,7 @@ async function processRootEntries(store, rootCollection, contentRoot, config, id
2967
3316
  }
2968
3317
  if (config?.exclude?.where?.(aiEntry))
2969
3318
  continue;
2970
- await runEntryTransform(aiEntry, doc.absolutePath, extractIdFromFilename(path7.basename(listEntry.relativePath)), config);
3319
+ await runEntryTransform(aiEntry, doc.absolutePath, extractIdFromFilename(path9.basename(listEntry.relativePath)), config);
2971
3320
  entries.push(aiEntry);
2972
3321
  const entryFilePath = `${listEntry.slug}.md`;
2973
3322
  const entryMarkdown = entryToMarkdown(aiEntry, config);
@@ -3015,19 +3364,19 @@ function docToAIEntry(doc, slug, entryTypeName, entryTypeConfig, cleanCollection
3015
3364
  };
3016
3365
  }
3017
3366
  function makeReadSibling(dir) {
3018
- const resolvedDir = path7.resolve(dir);
3367
+ const resolvedDir = path9.resolve(dir);
3019
3368
  return async (name) => {
3020
- if (!name || name.includes("/") || name.includes("\\") || path7.isAbsolute(name) || hasTraversalSequence(name)) {
3369
+ if (!name || name.includes("/") || name.includes("\\") || path9.isAbsolute(name) || hasTraversalSequence(name)) {
3021
3370
  return null;
3022
3371
  }
3023
- const abs = path7.resolve(resolvedDir, name);
3024
- if (path7.dirname(abs) !== resolvedDir)
3372
+ const abs = path9.resolve(resolvedDir, name);
3373
+ if (path9.dirname(abs) !== resolvedDir)
3025
3374
  return null;
3026
3375
  try {
3027
- const stats = await fs7.lstat(abs);
3376
+ const stats = await fs9.lstat(abs);
3028
3377
  if (!stats.isFile())
3029
3378
  return null;
3030
- return await fs7.readFile(abs, "utf8");
3379
+ return await fs9.readFile(abs, "utf8");
3031
3380
  } catch (err) {
3032
3381
  if (isNodeError(err) && (err.code === "ENOENT" || err.code === "ENOTDIR"))
3033
3382
  return null;
@@ -3041,7 +3390,7 @@ async function runEntryTransform(entry, absolutePath, contentId, config) {
3041
3390
  return;
3042
3391
  const ctx = {
3043
3392
  contentId: contentId ?? "",
3044
- readSibling: makeReadSibling(path7.dirname(absolutePath))
3393
+ readSibling: makeReadSibling(path9.dirname(absolutePath))
3045
3394
  };
3046
3395
  try {
3047
3396
  const appended = await transform(entry, ctx);
@@ -3078,33 +3427,33 @@ function matchesBundleFilter(entry, filter, contentRoot) {
3078
3427
  }
3079
3428
 
3080
3429
  // dist/branch-workspace.js
3081
- import path12 from "node:path";
3430
+ import path14 from "node:path";
3082
3431
 
3083
3432
  // dist/branch-metadata.js
3084
- import { randomUUID } from "node:crypto";
3085
- import fs9 from "node:fs/promises";
3086
- import path9 from "node:path";
3433
+ import { randomUUID as randomUUID2 } from "node:crypto";
3434
+ import fs11 from "node:fs/promises";
3435
+ import path11 from "node:path";
3087
3436
 
3088
3437
  // dist/branch-registry.js
3089
- import fs8 from "node:fs/promises";
3090
- import path8 from "node:path";
3438
+ import fs10 from "node:fs/promises";
3439
+ import path10 from "node:path";
3091
3440
  var REGISTRY_FILE = "branches.json";
3092
3441
  var REGISTRY_STALE_FILE = "branches.stale.json";
3093
3442
  var REGISTRY_TEMP_FILE = "branches.tmp.json";
3094
3443
  var REGISTRY_VERSION = 1;
3095
3444
  var BranchRegistry = class {
3096
3445
  constructor(root) {
3097
- this.root = path8.resolve(root);
3098
- this.registryPath = path8.join(this.root, REGISTRY_FILE);
3099
- this.stalePath = path8.join(this.root, REGISTRY_STALE_FILE);
3100
- this.tempPath = path8.join(this.root, REGISTRY_TEMP_FILE);
3446
+ this.root = path10.resolve(root);
3447
+ this.registryPath = path10.join(this.root, REGISTRY_FILE);
3448
+ this.stalePath = path10.join(this.root, REGISTRY_STALE_FILE);
3449
+ this.tempPath = path10.join(this.root, REGISTRY_TEMP_FILE);
3101
3450
  }
3102
3451
  /**
3103
3452
  * Returns all branches. Uses cache if fresh, regenerates if stale.
3104
3453
  */
3105
3454
  async list() {
3106
3455
  try {
3107
- const raw = await fs8.readFile(this.registryPath, "utf8");
3456
+ const raw = await fs10.readFile(this.registryPath, "utf8");
3108
3457
  const parsed = JSON.parse(raw);
3109
3458
  if (!parsed.version || !Array.isArray(parsed.branches)) {
3110
3459
  return await this.regenerate();
@@ -3130,7 +3479,7 @@ var BranchRegistry = class {
3130
3479
  */
3131
3480
  async invalidate() {
3132
3481
  try {
3133
- await fs8.rename(this.registryPath, this.stalePath);
3482
+ await fs10.rename(this.registryPath, this.stalePath);
3134
3483
  } catch (err) {
3135
3484
  if (!isNotFoundError(err)) {
3136
3485
  throw err;
@@ -3144,20 +3493,20 @@ var BranchRegistry = class {
3144
3493
  async regenerate() {
3145
3494
  const branches = await this.scanBranchDirectories();
3146
3495
  const uniqueTempPath = `${this.tempPath}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
3147
- await fs8.mkdir(this.root, { recursive: true });
3496
+ await fs10.mkdir(this.root, { recursive: true });
3148
3497
  const snapshot = {
3149
3498
  version: REGISTRY_VERSION,
3150
3499
  branches
3151
3500
  };
3152
- await fs8.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
3501
+ await fs10.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
3153
3502
  try {
3154
- await fs8.rename(uniqueTempPath, this.registryPath);
3503
+ await fs10.rename(uniqueTempPath, this.registryPath);
3155
3504
  } catch (err) {
3156
- await fs8.unlink(uniqueTempPath).catch(() => {
3505
+ await fs10.unlink(uniqueTempPath).catch(() => {
3157
3506
  });
3158
3507
  throw err;
3159
3508
  }
3160
- await fs8.unlink(this.stalePath).catch(() => {
3509
+ await fs10.unlink(this.stalePath).catch(() => {
3161
3510
  });
3162
3511
  return branches;
3163
3512
  }
@@ -3167,12 +3516,12 @@ var BranchRegistry = class {
3167
3516
  async scanBranchDirectories() {
3168
3517
  const branches = [];
3169
3518
  try {
3170
- const entries = await fs8.readdir(this.root, { withFileTypes: true });
3519
+ const entries = await fs10.readdir(this.root, { withFileTypes: true });
3171
3520
  for (const entry of entries) {
3172
3521
  if (!entry.isDirectory() || entry.name.startsWith(".")) {
3173
3522
  continue;
3174
3523
  }
3175
- const branchRoot = path8.join(this.root, entry.name);
3524
+ const branchRoot = path10.join(this.root, entry.name);
3176
3525
  const meta = await BranchMetadataFileManager.loadOnly(branchRoot);
3177
3526
  if (meta) {
3178
3527
  branches.push({
@@ -3204,8 +3553,8 @@ var BranchMetadataConflictError = class extends Error {
3204
3553
  };
3205
3554
  var BranchMetadataFileManager = class _BranchMetadataFileManager {
3206
3555
  constructor(branchRoot, baseRoot) {
3207
- this.branchRoot = path9.resolve(branchRoot);
3208
- this.filePath = path9.join(this.branchRoot, BRANCH_META_DIR, BRANCH_META_FILE);
3556
+ this.branchRoot = path11.resolve(branchRoot);
3557
+ this.filePath = path11.join(this.branchRoot, BRANCH_META_DIR, BRANCH_META_FILE);
3209
3558
  this.baseRoot = baseRoot;
3210
3559
  }
3211
3560
  /**
@@ -3213,9 +3562,9 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
3213
3562
  * Use this for read-only access (e.g., in registry scanning or loadBranchContext).
3214
3563
  */
3215
3564
  static async loadOnly(branchRoot) {
3216
- const filePath = path9.join(path9.resolve(branchRoot), BRANCH_META_DIR, BRANCH_META_FILE);
3565
+ const filePath = path11.join(path11.resolve(branchRoot), BRANCH_META_DIR, BRANCH_META_FILE);
3217
3566
  try {
3218
- const raw = await fs9.readFile(filePath, "utf8");
3567
+ const raw = await fs11.readFile(filePath, "utf8");
3219
3568
  return JSON.parse(raw);
3220
3569
  } catch (err) {
3221
3570
  if (isNotFoundError(err)) {
@@ -3233,7 +3582,7 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
3233
3582
  }
3234
3583
  async load() {
3235
3584
  try {
3236
- const raw = await fs9.readFile(this.filePath, "utf8");
3585
+ const raw = await fs11.readFile(this.filePath, "utf8");
3237
3586
  const parsed = JSON.parse(raw);
3238
3587
  const version = parsed.version ?? 0;
3239
3588
  return { meta: parsed, version };
@@ -3250,38 +3599,38 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
3250
3599
  */
3251
3600
  async write(meta, expectedVersion) {
3252
3601
  const newVersion = expectedVersion === null ? 1 : expectedVersion + 1;
3253
- const writeId = randomUUID();
3602
+ const writeId = randomUUID2();
3254
3603
  const payload = {
3255
3604
  ...meta,
3256
3605
  schemaVersion: meta.schemaVersion ?? CURRENT_SCHEMA_VERSION,
3257
3606
  version: newVersion,
3258
3607
  writeId
3259
3608
  };
3260
- await fs9.mkdir(path9.dirname(this.filePath), { recursive: true });
3609
+ await fs11.mkdir(path11.dirname(this.filePath), { recursive: true });
3261
3610
  const content = JSON.stringify(payload, null, 2) + "\n";
3262
3611
  if (expectedVersion === null) {
3263
3612
  const tempPath2 = `${this.filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
3264
- await fs9.writeFile(tempPath2, content, "utf-8");
3613
+ await fs11.writeFile(tempPath2, content, "utf-8");
3265
3614
  try {
3266
- await fs9.link(tempPath2, this.filePath);
3615
+ await fs11.link(tempPath2, this.filePath);
3267
3616
  } catch (err) {
3268
- await fs9.unlink(tempPath2).catch(() => {
3617
+ await fs11.unlink(tempPath2).catch(() => {
3269
3618
  });
3270
3619
  if (isFileExistsError(err)) {
3271
3620
  throw new BranchMetadataConflictError();
3272
3621
  }
3273
3622
  throw err;
3274
3623
  }
3275
- await fs9.unlink(tempPath2).catch(() => {
3624
+ await fs11.unlink(tempPath2).catch(() => {
3276
3625
  });
3277
3626
  return { version: newVersion, writeId };
3278
3627
  }
3279
3628
  const tempPath = `${this.filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
3280
- await fs9.writeFile(tempPath, content, "utf-8");
3629
+ await fs11.writeFile(tempPath, content, "utf-8");
3281
3630
  try {
3282
3631
  let currentVersion = null;
3283
3632
  try {
3284
- const current = JSON.parse(await fs9.readFile(this.filePath, "utf-8"));
3633
+ const current = JSON.parse(await fs11.readFile(this.filePath, "utf-8"));
3285
3634
  currentVersion = current.version ?? 0;
3286
3635
  } catch {
3287
3636
  currentVersion = null;
@@ -3289,13 +3638,13 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
3289
3638
  if (currentVersion !== expectedVersion) {
3290
3639
  throw new BranchMetadataConflictError();
3291
3640
  }
3292
- await fs9.rename(tempPath, this.filePath);
3293
- const afterWrite = JSON.parse(await fs9.readFile(this.filePath, "utf-8"));
3641
+ await fs11.rename(tempPath, this.filePath);
3642
+ const afterWrite = JSON.parse(await fs11.readFile(this.filePath, "utf-8"));
3294
3643
  if (afterWrite.writeId !== writeId) {
3295
3644
  throw new BranchMetadataConflictError();
3296
3645
  }
3297
3646
  } catch (err) {
3298
- await fs9.unlink(tempPath).catch(() => {
3647
+ await fs11.unlink(tempPath).catch(() => {
3299
3648
  });
3300
3649
  throw err;
3301
3650
  }
@@ -3309,7 +3658,7 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
3309
3658
  if (err instanceof BranchMetadataConflictError && attempt < maxAttempts) {
3310
3659
  const baseDelay = Math.min(10 * Math.pow(2, attempt - 1), 100);
3311
3660
  const jitter = Math.random() * baseDelay;
3312
- await new Promise((resolve) => setTimeout(resolve, baseDelay + jitter));
3661
+ await new Promise((resolve2) => setTimeout(resolve2, baseDelay + jitter));
3313
3662
  continue;
3314
3663
  }
3315
3664
  throw err;
@@ -3358,8 +3707,8 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
3358
3707
  * Invalidates the registry cache so next list() call regenerates from branch.json files.
3359
3708
  */
3360
3709
  async invalidateRegistry() {
3361
- const registry = new BranchRegistry(this.baseRoot);
3362
- await registry.invalidate();
3710
+ const registry2 = new BranchRegistry(this.baseRoot);
3711
+ await registry2.invalidate();
3363
3712
  }
3364
3713
  };
3365
3714
  var getBranchMetadataFileManager = (branchRoot, baseRoot) => {
@@ -3386,8 +3735,8 @@ var loadBranchContext = async (options) => {
3386
3735
  init_operating_mode();
3387
3736
 
3388
3737
  // dist/git-manager.js
3389
- import fs11 from "node:fs/promises";
3390
- import path11 from "node:path";
3738
+ import fs13 from "node:fs/promises";
3739
+ import path13 from "node:path";
3391
3740
  import { simpleGit as simpleGit2 } from "simple-git";
3392
3741
 
3393
3742
  // dist/utils/git.js
@@ -3411,21 +3760,41 @@ async function resolveBaseBranch(options) {
3411
3760
  }
3412
3761
 
3413
3762
  // dist/utils/provisioning-lock.js
3414
- import fs10 from "node:fs/promises";
3415
- import path10 from "node:path";
3763
+ import fs12 from "node:fs/promises";
3764
+ import path12 from "node:path";
3416
3765
  import lockfile from "proper-lockfile";
3417
3766
  async function acquireProvisioningLock(lockTargetDir, lockName) {
3418
- await fs10.mkdir(lockTargetDir, { recursive: true });
3767
+ await fs12.mkdir(lockTargetDir, { recursive: true });
3419
3768
  return lockfile.lock(lockTargetDir, {
3420
- lockfilePath: path10.join(lockTargetDir, lockName),
3769
+ lockfilePath: path12.join(lockTargetDir, lockName),
3421
3770
  retries: { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true },
3422
3771
  stale: 3e4
3423
3772
  });
3424
3773
  }
3425
3774
 
3426
3775
  // dist/git-manager.js
3427
- var log2 = createDebugLogger({ prefix: "GitManager" });
3776
+ var log3 = createDebugLogger({ prefix: "GitManager" });
3428
3777
  var remoteInitLocks = /* @__PURE__ */ new Map();
3778
+ async function ensureGitExcludePattern(repoPath, pattern) {
3779
+ const excludePath = path13.join(repoPath, ".git", "info", "exclude");
3780
+ await fs13.mkdir(path13.dirname(excludePath), { recursive: true });
3781
+ let content = "";
3782
+ try {
3783
+ content = await fs13.readFile(excludePath, "utf-8");
3784
+ } catch (err) {
3785
+ if (!isNotFoundError(err))
3786
+ throw err;
3787
+ }
3788
+ const lines = content.split("\n");
3789
+ if (lines.some((line) => line.trim() === pattern)) {
3790
+ log3.debug("git", "Pattern already in .git/info/exclude", { pattern });
3791
+ return;
3792
+ }
3793
+ const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
3794
+ const newContent = content + (needsLeadingNewline ? "\n" : "") + pattern + "\n";
3795
+ await fs13.writeFile(excludePath, newContent, "utf-8");
3796
+ log3.debug("git", "Added pattern to .git/info/exclude", { pattern });
3797
+ }
3429
3798
  var GitConflictError = class extends Error {
3430
3799
  constructor(conflictedFiles) {
3431
3800
  super(`Git conflict in ${conflictedFiles.length} file(s): ${conflictedFiles.join(", ")}`);
@@ -3435,21 +3804,22 @@ var GitConflictError = class extends Error {
3435
3804
  };
3436
3805
  var GitManager = class _GitManager {
3437
3806
  constructor(options, gitOptions) {
3438
- this.repoPath = path11.resolve(options.repoPath);
3807
+ this.repoPath = path13.resolve(options.repoPath);
3439
3808
  this.baseBranch = options.baseBranch ?? "main";
3440
3809
  this.remote = options.remote ?? "origin";
3810
+ this.skipIndexMarker = options.skipIndexMarker ?? false;
3441
3811
  this.git = simpleGit2({ baseDir: this.repoPath, ...gitOptions });
3442
- this.git.env("GIT_CEILING_DIRECTORIES", path11.dirname(this.repoPath));
3812
+ this.git.env("GIT_CEILING_DIRECTORIES", path13.dirname(this.repoPath));
3443
3813
  }
3444
3814
  static async cloneRepo(remoteUrl, targetPath, baseBranch = "main") {
3445
- log2.debug("git", "Cloning repository", {
3815
+ log3.debug("git", "Cloning repository", {
3446
3816
  remoteUrl,
3447
3817
  targetPath,
3448
3818
  baseBranch
3449
3819
  });
3450
3820
  const git = simpleGit2();
3451
3821
  await git.clone(remoteUrl, targetPath, ["--branch", baseBranch, "--single-branch"]);
3452
- log2.debug("git", "Clone complete");
3822
+ log3.debug("git", "Clone complete");
3453
3823
  }
3454
3824
  /**
3455
3825
  * Initializes a local bare git repository to simulate a remote for dev mode.
@@ -3469,30 +3839,30 @@ var GitManager = class _GitManager {
3469
3839
  static async ensureLocalSimulatedRemote(options) {
3470
3840
  const existingLock = remoteInitLocks.get(options.remotePath);
3471
3841
  if (existingLock) {
3472
- log2.debug("git", "Waiting for existing remote initialization", {
3842
+ log3.debug("git", "Waiting for existing remote initialization", {
3473
3843
  remotePath: options.remotePath
3474
3844
  });
3475
3845
  await existingLock;
3476
3846
  }
3477
- const lockPromise = log2.timed("git", "ensureLocalSimulatedRemote", async () => {
3847
+ const lockPromise = log3.timed("git", "ensureLocalSimulatedRemote", async () => {
3478
3848
  let releaseLock;
3479
3849
  try {
3480
- log2.debug("git", "Initializing local simulated remote", {
3850
+ log3.debug("git", "Initializing local simulated remote", {
3481
3851
  remotePath: options.remotePath,
3482
3852
  baseBranch: options.baseBranch
3483
3853
  });
3484
- const remoteParent = path11.dirname(options.remotePath);
3854
+ const remoteParent = path13.dirname(options.remotePath);
3485
3855
  releaseLock = await acquireProvisioningLock(remoteParent, ".remote-init.lock");
3486
3856
  let remoteExists = false;
3487
3857
  try {
3488
- const stat = await fs11.stat(options.remotePath);
3858
+ const stat = await fs13.stat(options.remotePath);
3489
3859
  remoteExists = stat.isDirectory();
3490
3860
  } catch (err) {
3491
3861
  if (!isNotFoundError(err))
3492
3862
  throw err;
3493
3863
  }
3494
3864
  if (remoteExists && await _GitManager.bareRemoteHasBranch(options.remotePath, options.baseBranch)) {
3495
- log2.debug("git", "Remote already has base branch, skipping");
3865
+ log3.debug("git", "Remote already has base branch, skipping");
3496
3866
  return;
3497
3867
  }
3498
3868
  let gitRoot = options.sourcePath;
@@ -3511,8 +3881,8 @@ var GitManager = class _GitManager {
3511
3881
  }
3512
3882
  let hasCommits = false;
3513
3883
  try {
3514
- const log4 = await sourceGit.log(["-1"]);
3515
- hasCommits = log4.total > 0;
3884
+ const log6 = await sourceGit.log(["-1"]);
3885
+ hasCommits = log6.total > 0;
3516
3886
  } catch {
3517
3887
  hasCommits = false;
3518
3888
  }
@@ -3524,12 +3894,12 @@ var GitManager = class _GitManager {
3524
3894
  throw new Error(`Cannot initialize local simulated remote: base branch '${options.baseBranch}' does not exist locally. Please checkout '${options.baseBranch}' first or provide an explicit remoteUrl.`);
3525
3895
  }
3526
3896
  if (remoteExists) {
3527
- log2.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
3897
+ log3.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
3528
3898
  remotePath: options.remotePath,
3529
3899
  baseBranch: options.baseBranch
3530
3900
  });
3531
3901
  } else {
3532
- log2.debug("git", "Creating bare remote repository");
3902
+ log3.debug("git", "Creating bare remote repository");
3533
3903
  await simpleGit2().raw([
3534
3904
  "init",
3535
3905
  "--bare",
@@ -3543,13 +3913,13 @@ var GitManager = class _GitManager {
3543
3913
  baseBranch: options.baseBranch,
3544
3914
  subdirectory: options.subdirectory
3545
3915
  });
3546
- log2.debug("git", "Remote initialization complete");
3916
+ log3.debug("git", "Remote initialization complete");
3547
3917
  } finally {
3548
3918
  if (releaseLock) {
3549
3919
  try {
3550
3920
  await releaseLock();
3551
3921
  } catch (err) {
3552
- log2.debug("git", "Failed to release remote-init lock", { err });
3922
+ log3.debug("git", "Failed to release remote-init lock", { err });
3553
3923
  }
3554
3924
  }
3555
3925
  remoteInitLocks.delete(options.remotePath);
@@ -3648,7 +4018,7 @@ var GitManager = class _GitManager {
3648
4018
  */
3649
4019
  static async validateGitRepoExists(repoPath) {
3650
4020
  try {
3651
- const stat = await fs11.stat(path11.join(repoPath, ".git"));
4021
+ const stat = await fs13.stat(path13.join(repoPath, ".git"));
3652
4022
  if (!stat.isDirectory()) {
3653
4023
  throw new Error(`Expected git repo at ${repoPath}`);
3654
4024
  }
@@ -3686,9 +4056,9 @@ var GitManager = class _GitManager {
3686
4056
  return process.env[config.envVarName];
3687
4057
  if (config.autoDetectRemotePath) {
3688
4058
  try {
3689
- const stat = await fs11.stat(config.autoDetectRemotePath);
4059
+ const stat = await fs13.stat(config.autoDetectRemotePath);
3690
4060
  if (stat.isDirectory()) {
3691
- log2.debug("git", "Auto-detected local remote", {
4061
+ log3.debug("git", "Auto-detected local remote", {
3692
4062
  path: config.autoDetectRemotePath
3693
4063
  });
3694
4064
  return config.autoDetectRemotePath;
@@ -3699,8 +4069,8 @@ var GitManager = class _GitManager {
3699
4069
  if (config.shouldAutoInitLocal) {
3700
4070
  const gitRoot = await this.findGitRoot();
3701
4071
  const sourceRoot = options.sourceRoot;
3702
- const sourcePath = sourceRoot ? path11.resolve(gitRoot, sourceRoot) : gitRoot;
3703
- const localRemotePath = path11.join(sourcePath, config.defaultRemotePath);
4072
+ const sourcePath = sourceRoot ? path13.resolve(gitRoot, sourceRoot) : gitRoot;
4073
+ const localRemotePath = path13.join(sourcePath, config.defaultRemotePath);
3704
4074
  await this.ensureLocalSimulatedRemote({
3705
4075
  remotePath: localRemotePath,
3706
4076
  sourcePath: gitRoot,
@@ -3726,24 +4096,24 @@ var GitManager = class _GitManager {
3726
4096
  const baseBranch = await resolveBaseBranch({
3727
4097
  defaultBaseBranch: options.baseBranch,
3728
4098
  mode: options.mode,
3729
- detectFrom: options.sourceRoot ? path11.resolve(process.cwd(), options.sourceRoot) : process.cwd()
4099
+ detectFrom: options.sourceRoot ? path13.resolve(process.cwd(), options.sourceRoot) : process.cwd()
3730
4100
  });
3731
4101
  const remoteName = options.remoteName ?? "origin";
3732
4102
  let repoExists = false;
3733
4103
  try {
3734
4104
  const checkGit = simpleGit2({ baseDir: options.workspacePath });
3735
- checkGit.env("GIT_CEILING_DIRECTORIES", path11.dirname(options.workspacePath));
4105
+ checkGit.env("GIT_CEILING_DIRECTORIES", path13.dirname(options.workspacePath));
3736
4106
  await checkGit.raw(["rev-parse", "--git-dir"]);
3737
4107
  repoExists = true;
3738
4108
  } catch {
3739
- const gitPath = path11.join(options.workspacePath, ".git");
4109
+ const gitPath = path13.join(options.workspacePath, ".git");
3740
4110
  try {
3741
- const stat = await fs11.stat(gitPath);
4111
+ const stat = await fs13.stat(gitPath);
3742
4112
  if (stat.isDirectory()) {
3743
- log2.debug("git", "Removing corrupt .git directory", {
4113
+ log3.debug("git", "Removing corrupt .git directory", {
3744
4114
  workspacePath: options.workspacePath
3745
4115
  });
3746
- await fs11.rm(gitPath, { recursive: true });
4116
+ await fs13.rm(gitPath, { recursive: true });
3747
4117
  }
3748
4118
  } catch (cleanupErr) {
3749
4119
  if (!isNotFoundError(cleanupErr))
@@ -3769,7 +4139,7 @@ var GitManager = class _GitManager {
3769
4139
  }
3770
4140
  justCloned = true;
3771
4141
  const freshGit = simpleGit2({ baseDir: options.workspacePath });
3772
- freshGit.env("GIT_CEILING_DIRECTORIES", path11.dirname(options.workspacePath));
4142
+ freshGit.env("GIT_CEILING_DIRECTORIES", path13.dirname(options.workspacePath));
3773
4143
  await freshGit.addConfig("canopycms.managed", "true");
3774
4144
  await freshGit.addConfig("user.name", options.gitBotAuthorName);
3775
4145
  await freshGit.addConfig("user.email", options.gitBotAuthorEmail);
@@ -3777,12 +4147,13 @@ var GitManager = class _GitManager {
3777
4147
  const git = new _GitManager({
3778
4148
  repoPath: options.workspacePath,
3779
4149
  baseBranch,
3780
- remote: remoteName
4150
+ remote: remoteName,
4151
+ skipIndexMarker: options.branchType === "orphan"
3781
4152
  });
3782
4153
  await git.git.addConfig("canopycms.managed", "true");
3783
4154
  await git.git.addConfig("user.name", options.gitBotAuthorName);
3784
4155
  await git.git.addConfig("user.email", options.gitBotAuthorEmail);
3785
- log2.debug("git", "Marked workspace as CanopyCMS-managed", {
4156
+ log3.debug("git", "Marked workspace as CanopyCMS-managed", {
3786
4157
  workspacePath: options.workspacePath
3787
4158
  });
3788
4159
  if (!justCloned) {
@@ -3817,7 +4188,33 @@ var GitManager = class _GitManager {
3817
4188
  tracking: s.tracking
3818
4189
  };
3819
4190
  }
4191
+ /**
4192
+ * Mark ContentStore ID indexes rooted at (or under) this repo as stale.
4193
+ * Called after operations that mutate the working tree (checkout/merge/rebase) so
4194
+ * ID→path lookups don't keep resolving to pre-mutation paths. Invoked in `finally`
4195
+ * blocks because even failed merges/rebases may have touched the tree before
4196
+ * aborting; over-invalidating is safe.
4197
+ *
4198
+ * Covers both scopes: in-process stores via the registry, and stores in OTHER
4199
+ * processes sharing the filesystem (worker vs Lambda on EFS) via the on-disk
4200
+ * generation marker — unless this manager targets a settings workspace
4201
+ * (skipIndexMarker), where only the free in-process invalidation runs.
4202
+ */
4203
+ async invalidateContentIndexes() {
4204
+ if (this.skipIndexMarker) {
4205
+ invalidateContentIndexesForRoot(this.repoPath);
4206
+ return;
4207
+ }
4208
+ await invalidateContentIndexesDurable(this.repoPath);
4209
+ }
3820
4210
  async checkoutBranch(branch) {
4211
+ try {
4212
+ await this.checkoutBranchInner(branch);
4213
+ } finally {
4214
+ await this.invalidateContentIndexes();
4215
+ }
4216
+ }
4217
+ async checkoutBranchInner(branch) {
3821
4218
  const branches = await this.git.branch();
3822
4219
  if (branches.all.includes(branch)) {
3823
4220
  await this.git.checkout(branch);
@@ -3841,6 +4238,13 @@ var GitManager = class _GitManager {
3841
4238
  }
3842
4239
  }
3843
4240
  async pullBase() {
4241
+ try {
4242
+ await this.pullBaseInner();
4243
+ } finally {
4244
+ await this.invalidateContentIndexes();
4245
+ }
4246
+ }
4247
+ async pullBaseInner() {
3844
4248
  await this.git.fetch(this.remote, this.baseBranch);
3845
4249
  try {
3846
4250
  await this.git.merge([`${this.remote}/${this.baseBranch}`]);
@@ -3861,6 +4265,13 @@ var GitManager = class _GitManager {
3861
4265
  }
3862
4266
  }
3863
4267
  async pullCurrentBranch() {
4268
+ try {
4269
+ await this.pullCurrentBranchInner();
4270
+ } finally {
4271
+ await this.invalidateContentIndexes();
4272
+ }
4273
+ }
4274
+ async pullCurrentBranchInner() {
3864
4275
  const branches = await this.git.branch();
3865
4276
  const currentBranch = branches.current;
3866
4277
  await this.git.fetch(this.remote, currentBranch);
@@ -3883,6 +4294,13 @@ var GitManager = class _GitManager {
3883
4294
  }
3884
4295
  }
3885
4296
  async rebaseOntoBase() {
4297
+ try {
4298
+ await this.rebaseOntoBaseInner();
4299
+ } finally {
4300
+ await this.invalidateContentIndexes();
4301
+ }
4302
+ }
4303
+ async rebaseOntoBaseInner() {
3886
4304
  await this.git.fetch(this.remote, this.baseBranch);
3887
4305
  try {
3888
4306
  await this.git.rebase([`${this.remote}/${this.baseBranch}`]);
@@ -3911,7 +4329,13 @@ var GitManager = class _GitManager {
3911
4329
  }
3912
4330
  async push(branch) {
3913
4331
  const target = branch ?? await this.git.revparse(["--abbrev-ref", "HEAD"]);
3914
- await this.git.push(this.remote, `${target}:${target}`, ["--set-upstream"]);
4332
+ await this.git.raw([
4333
+ "push",
4334
+ "--set-upstream",
4335
+ "--end-of-options",
4336
+ this.remote,
4337
+ `${target}:${target}`
4338
+ ]);
3915
4339
  }
3916
4340
  async ensureAuthor(author) {
3917
4341
  const config = await this.git.listConfig();
@@ -3965,7 +4389,7 @@ var GitManager = class _GitManager {
3965
4389
  */
3966
4390
  async forcePush(branch) {
3967
4391
  const target = branch ?? await this.git.revparse(["--abbrev-ref", "HEAD"]);
3968
- await this.git.push(this.remote, target, ["--force-with-lease"]);
4392
+ await this.git.raw(["push", "--force-with-lease", "--end-of-options", this.remote, target]);
3969
4393
  }
3970
4394
  /**
3971
4395
  * Get remote URL for current repo
@@ -3985,24 +4409,7 @@ var GitManager = class _GitManager {
3985
4409
  * This is idempotent - if the pattern already exists, it won't be added again.
3986
4410
  */
3987
4411
  async ensureGitExclude(pattern) {
3988
- const excludePath = path11.join(this.repoPath, ".git", "info", "exclude");
3989
- await fs11.mkdir(path11.dirname(excludePath), { recursive: true });
3990
- let content = "";
3991
- try {
3992
- content = await fs11.readFile(excludePath, "utf-8");
3993
- } catch (err) {
3994
- if (!isNotFoundError(err))
3995
- throw err;
3996
- }
3997
- const lines = content.split("\n");
3998
- if (lines.some((line) => line.trim() === pattern)) {
3999
- log2.debug("git", "Pattern already in .git/info/exclude", { pattern });
4000
- return;
4001
- }
4002
- const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
4003
- const newContent = content + (needsLeadingNewline ? "\n" : "") + pattern + "\n";
4004
- await fs11.writeFile(excludePath, newContent, "utf-8");
4005
- log2.debug("git", "Added pattern to .git/info/exclude", { pattern });
4412
+ await ensureGitExcludePattern(this.repoPath, pattern);
4006
4413
  }
4007
4414
  /**
4008
4415
  * Create an orphan branch for settings (permissions/groups).
@@ -4010,16 +4417,24 @@ var GitManager = class _GitManager {
4010
4417
  * Orphan branches have no shared history with other branches - they start fresh.
4011
4418
  * This is perfect for deployment-specific settings that shouldn't pollute content history.
4012
4419
  *
4013
- * The branch contains only settings files in .canopy-meta/ (groups.json, permissions.json).
4420
+ * The branch contains only settings files committed by explicit path
4421
+ * (e.g. permissions.json, groups.json at the workspace root).
4014
4422
  *
4015
4423
  * @param branchName - Name of the orphan branch (e.g., 'canopycms-settings-prod')
4016
4424
  * @param initialFiles - Files to commit to the new branch (e.g., { 'permissions.json': '{}', 'groups.json': '{}' })
4017
4425
  */
4018
4426
  async createOrphanSettingsBranch(branchName, initialFiles) {
4019
- log2.debug("git", "Creating orphan settings branch", { branchName });
4427
+ try {
4428
+ await this.createOrphanSettingsBranchInner(branchName, initialFiles);
4429
+ } finally {
4430
+ await this.invalidateContentIndexes();
4431
+ }
4432
+ }
4433
+ async createOrphanSettingsBranchInner(branchName, initialFiles) {
4434
+ log3.debug("git", "Creating orphan settings branch", { branchName });
4020
4435
  const branches = await this.git.branch();
4021
4436
  if (branches.all.includes(branchName)) {
4022
- log2.debug("git", "Orphan branch already exists", { branchName });
4437
+ log3.debug("git", "Orphan branch already exists", { branchName });
4023
4438
  await this.git.checkout(branchName);
4024
4439
  return;
4025
4440
  }
@@ -4029,25 +4444,25 @@ var GitManager = class _GitManager {
4029
4444
  } catch {
4030
4445
  }
4031
4446
  for (const [filePath, content] of Object.entries(initialFiles)) {
4032
- const absolutePath = path11.join(this.repoPath, filePath);
4033
- await fs11.mkdir(path11.dirname(absolutePath), { recursive: true });
4034
- await fs11.writeFile(absolutePath, content, "utf-8");
4447
+ const absolutePath = path13.join(this.repoPath, filePath);
4448
+ await fs13.mkdir(path13.dirname(absolutePath), { recursive: true });
4449
+ await fs13.writeFile(absolutePath, content, "utf-8");
4035
4450
  await this.git.add(filePath);
4036
4451
  }
4037
4452
  await this.git.commit("Initialize settings branch", ["--allow-empty"]);
4038
- log2.debug("git", "Orphan settings branch created", { branchName });
4453
+ log3.debug("git", "Orphan settings branch created", { branchName });
4039
4454
  }
4040
4455
  };
4041
4456
 
4042
4457
  // dist/branch-workspace.js
4043
- var log3 = createDebugLogger({ prefix: "BranchWorkspace" });
4458
+ var log4 = createDebugLogger({ prefix: "BranchWorkspace" });
4044
4459
  var workspaceInitLocks = /* @__PURE__ */ new Map();
4045
4460
  var BranchWorkspaceManager = class {
4046
4461
  constructor(config) {
4047
4462
  this.config = config;
4048
4463
  }
4049
4464
  async ensureGitWorkspace(options) {
4050
- return log3.timed("workspace", "ensureGitWorkspace", async () => {
4465
+ return log4.timed("workspace", "ensureGitWorkspace", async () => {
4051
4466
  const existingLock = workspaceInitLocks.get(options.branchRoot);
4052
4467
  if (existingLock) {
4053
4468
  await existingLock;
@@ -4056,11 +4471,11 @@ var BranchWorkspaceManager = class {
4056
4471
  const lockPromise = (async () => {
4057
4472
  let releaseLock;
4058
4473
  try {
4059
- log3.debug("workspace", "Ensuring git workspace", {
4474
+ log4.debug("workspace", "Ensuring git workspace", {
4060
4475
  branchName: options.branchName,
4061
4476
  mode: options.mode
4062
4477
  });
4063
- releaseLock = await acquireProvisioningLock(path12.dirname(options.branchRoot), `.${path12.basename(options.branchRoot)}.init.lock`);
4478
+ releaseLock = await acquireProvisioningLock(path14.dirname(options.branchRoot), `.${path14.basename(options.branchRoot)}.init.lock`);
4064
4479
  await GitManager.initializeWorkspace({
4065
4480
  workspacePath: options.branchRoot,
4066
4481
  branchName: options.branchName,
@@ -4080,7 +4495,7 @@ var BranchWorkspaceManager = class {
4080
4495
  try {
4081
4496
  await releaseLock();
4082
4497
  } catch (err) {
4083
- log3.debug("workspace", "Failed to release workspace-init lock", { err });
4498
+ log4.debug("workspace", "Failed to release workspace-init lock", { err });
4084
4499
  }
4085
4500
  }
4086
4501
  workspaceInitLocks.delete(options.branchRoot);
@@ -4100,7 +4515,7 @@ var BranchWorkspaceManager = class {
4100
4515
  const baseBranch = await resolveBaseBranch({
4101
4516
  defaultBaseBranch: this.config.defaultBaseBranch,
4102
4517
  mode,
4103
- detectFrom: this.config.sourceRoot ? path12.resolve(process.cwd(), this.config.sourceRoot) : void 0
4518
+ detectFrom: this.config.sourceRoot ? path14.resolve(process.cwd(), this.config.sourceRoot) : void 0
4104
4519
  });
4105
4520
  await this.ensureGitWorkspace({
4106
4521
  branchRoot,
@@ -4183,6 +4598,384 @@ async function resolveBranchRoot(config) {
4183
4598
  return context.branchRoot;
4184
4599
  }
4185
4600
 
4601
+ // dist/content-listing.js
4602
+ import fs14 from "node:fs/promises";
4603
+ import path15 from "node:path";
4604
+ import matter2 from "gray-matter";
4605
+ import { parse as yamlParse2 } from "yaml";
4606
+ var log5 = createDebugLogger({ prefix: "ContentListing" });
4607
+ var normalizePath = (root, target) => {
4608
+ const result = validateAndNormalizePath(root, target);
4609
+ if (!result.valid) {
4610
+ throw new ContentStoreError(result.error || "Path traversal detected", "VALIDATION");
4611
+ }
4612
+ return result.normalizedPath;
4613
+ };
4614
+ var readEntryData = async (filePath, format, bodyFieldName = "body") => {
4615
+ try {
4616
+ const raw = await fs14.readFile(filePath, "utf8");
4617
+ if (format === "json") {
4618
+ return asRecord(JSON.parse(raw));
4619
+ }
4620
+ if (format === "yaml") {
4621
+ return asRecord(yamlParse2(raw));
4622
+ }
4623
+ const parsed = matter2(raw);
4624
+ const data = parsed.data ?? {};
4625
+ if (parsed.content) {
4626
+ data[bodyFieldName] = parsed.content;
4627
+ }
4628
+ return data;
4629
+ } catch (err) {
4630
+ if (isNotFoundError(err))
4631
+ return {};
4632
+ log5.warn("readEntryData", `Failed to read entry data from ${filePath}: ${getErrorMessage(err)}`);
4633
+ return {};
4634
+ }
4635
+ };
4636
+ var parseTypedFilename = (filename, entryTypes) => {
4637
+ const lastDot = filename.lastIndexOf(".");
4638
+ if (lastDot === -1)
4639
+ return null;
4640
+ const nameWithoutExt = filename.slice(0, lastDot);
4641
+ const parts = nameWithoutExt.split(".");
4642
+ if (parts.length >= 3) {
4643
+ const potentialType = parts[0];
4644
+ const matchingType = entryTypes.find((e) => e.name === potentialType);
4645
+ if (matchingType) {
4646
+ const id = parts[parts.length - 1];
4647
+ if (!isValidId(id))
4648
+ return null;
4649
+ const slug = parts.slice(1, -1).join(".").toLowerCase();
4650
+ return {
4651
+ type: potentialType,
4652
+ slug,
4653
+ id
4654
+ };
4655
+ }
4656
+ }
4657
+ return null;
4658
+ };
4659
+ async function listEntries(branchRoot, flatSchema, contentRootName, options) {
4660
+ const rootPath = options?.rootPath ?? contentRootName;
4661
+ const extract = options?.extract;
4662
+ const filter = options?.filter;
4663
+ const customSort = options?.sort;
4664
+ const collections = flatSchema.filter((item) => item.type === "collection" && item.entries !== void 0 && (item.logicalPath === rootPath || item.logicalPath.startsWith(`${rootPath}/`)));
4665
+ const collectionResults = await Promise.all(collections.map(async (collection) => {
4666
+ const entries = await listCollectionEntries(branchRoot, collection);
4667
+ return entries.map((entry) => ({ entry, collection }));
4668
+ }));
4669
+ const contentPrefix = contentRootName ? `${contentRootName}/` : "";
4670
+ const items = [];
4671
+ for (const results of collectionResults) {
4672
+ for (const { entry, collection } of results) {
4673
+ const pathWithoutRoot = entry.logicalPath.startsWith(contentPrefix) ? entry.logicalPath.slice(contentPrefix.length) : entry.logicalPath;
4674
+ const pathSegments = pathWithoutRoot.split("/").filter(Boolean);
4675
+ const urlSegments = entry.slug === "index" ? pathSegments.slice(0, -1) : pathSegments;
4676
+ const urlPath = urlSegments.length > 0 ? `/${urlSegments.join("/")}`.toLowerCase() : "/";
4677
+ const raw = entry.data;
4678
+ const meta = {
4679
+ entryPath: entry.logicalPath,
4680
+ entryType: entry.entryType,
4681
+ format: entry.format
4682
+ };
4683
+ const data = extract ? extract(raw, meta) : raw;
4684
+ const item = {
4685
+ pathSegments,
4686
+ urlPath,
4687
+ slug: entry.slug,
4688
+ entryPath: entry.logicalPath,
4689
+ entryId: entry.contentId,
4690
+ collectionId: collection.contentId,
4691
+ collectionPath: entry.collectionPath,
4692
+ entryType: entry.entryType,
4693
+ format: entry.format,
4694
+ data,
4695
+ schema: collection.entries?.find((e) => e.name === entry.entryType)?.schema
4696
+ };
4697
+ if (filter && !filter(item))
4698
+ continue;
4699
+ items.push(item);
4700
+ }
4701
+ }
4702
+ if (customSort) {
4703
+ items.sort(customSort);
4704
+ } else {
4705
+ items.sort((a, b) => a.entryPath.localeCompare(b.entryPath));
4706
+ }
4707
+ return items;
4708
+ }
4709
+ var listCollectionEntries = async (root, collection) => {
4710
+ if (collection.type !== "collection" || !collection.entries) {
4711
+ return [];
4712
+ }
4713
+ const entryTypes = collection.entries;
4714
+ const extToTypes = /* @__PURE__ */ new Map();
4715
+ for (const entryType of entryTypes) {
4716
+ const ext = getFormatExtension(entryType.format);
4717
+ const existing = extToTypes.get(ext) || [];
4718
+ existing.push(entryType);
4719
+ extToTypes.set(ext, existing);
4720
+ }
4721
+ const validExts = Array.from(extToTypes.keys());
4722
+ const collectionRoot = await resolveCollectionPath(root, collection.logicalPath);
4723
+ if (!collectionRoot) {
4724
+ return [];
4725
+ }
4726
+ normalizePath(root, collectionRoot);
4727
+ let dirents;
4728
+ try {
4729
+ dirents = await fs14.readdir(collectionRoot, { withFileTypes: true });
4730
+ } catch (err) {
4731
+ if (isNotFoundError(err))
4732
+ return [];
4733
+ throw err;
4734
+ }
4735
+ 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));
4736
+ const entries = await Promise.all(files.map(async (file) => {
4737
+ const absolutePath = path15.join(collectionRoot, file.name);
4738
+ const relativePath = normalizePath(root, absolutePath);
4739
+ const parsed = parseTypedFilename(file.name, entryTypes);
4740
+ if (!parsed) {
4741
+ log5.warn("listCollectionEntries", `Skipping file with unrecognized filename format: ${file.name} (expected {type}.{slug}.{id}.{ext} with a known entry type and valid 12-char Base58 ID)`);
4742
+ return null;
4743
+ }
4744
+ const { type: entryTypeName, slug, id: contentId } = parsed;
4745
+ const entryType = entryTypes.find((e) => e.name === entryTypeName);
4746
+ const format = entryType?.format || "json";
4747
+ const bodyField = entryType?.schema ? findBodyFieldName(entryType.schema) : "body";
4748
+ const [stats, data] = await Promise.all([
4749
+ fs14.stat(absolutePath),
4750
+ readEntryData(absolutePath, format, bodyField)
4751
+ ]);
4752
+ const item = {
4753
+ logicalPath: `${collection.logicalPath}/${slug}`,
4754
+ contentId,
4755
+ slug,
4756
+ collectionPath: collection.logicalPath,
4757
+ collectionName: collection.name,
4758
+ format,
4759
+ entryType: entryTypeName,
4760
+ physicalPath: relativePath,
4761
+ data,
4762
+ updatedAt: stats.mtime.toISOString()
4763
+ };
4764
+ return item;
4765
+ }));
4766
+ return entries.filter((e) => e !== null);
4767
+ };
4768
+
4769
+ // dist/validation/entry-validator.js
4770
+ init_config2();
4771
+
4772
+ // dist/validation/field-traversal.js
4773
+ function isPlainRecord(value) {
4774
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4775
+ }
4776
+ function resolveBlockItem(blockField, item) {
4777
+ const templateName = typeof item.template === "string" ? item.template : typeof item._type === "string" ? item._type : void 0;
4778
+ if (!templateName)
4779
+ return void 0;
4780
+ const template = blockField.templates?.find((t) => t.name === templateName);
4781
+ if (!template?.fields)
4782
+ return void 0;
4783
+ const data = isPlainRecord(item.value) ? item.value : item;
4784
+ return { fields: template.fields, data };
4785
+ }
4786
+
4787
+ // dist/validation/entry-validator.js
4788
+ var KNOWN_FIELD_TYPES = new Set(fieldTypes);
4789
+ var STRING_FIELD_TYPES = /* @__PURE__ */ new Set(["string", "rich-text", "markdown", "mdx", "image", "code"]);
4790
+ function isPlainRecord2(value) {
4791
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4792
+ }
4793
+ function referenceValueId(value) {
4794
+ if (typeof value === "string")
4795
+ return value;
4796
+ if (isPlainRecord2(value) && typeof value.id === "string")
4797
+ return value.id;
4798
+ return void 0;
4799
+ }
4800
+ var selectOptionValues = (field) => (field.options ?? []).map((opt) => typeof opt === "string" ? opt : opt.value);
4801
+ function validateScalar(field, value, path18) {
4802
+ switch (field.type) {
4803
+ case "number":
4804
+ if (typeof value !== "number" || !Number.isFinite(value)) {
4805
+ return [{ fieldPath: path18, message: "Expected a number" }];
4806
+ }
4807
+ return [];
4808
+ case "boolean":
4809
+ if (typeof value !== "boolean") {
4810
+ return [{ fieldPath: path18, message: "Expected true or false" }];
4811
+ }
4812
+ return [];
4813
+ case "datetime":
4814
+ if (typeof value !== "string" || value !== "" && Number.isNaN(Date.parse(value))) {
4815
+ return [{ fieldPath: path18, message: "Expected a valid date/time string" }];
4816
+ }
4817
+ return [];
4818
+ case "select": {
4819
+ if (typeof value !== "string") {
4820
+ return [{ fieldPath: path18, message: "Expected a selection" }];
4821
+ }
4822
+ const options = selectOptionValues(field);
4823
+ if (value !== "" && !options.includes(value)) {
4824
+ return [{ fieldPath: path18, message: `Must be one of: ${options.join(", ")}` }];
4825
+ }
4826
+ return [];
4827
+ }
4828
+ case "reference":
4829
+ if (referenceValueId(value) === void 0) {
4830
+ return [{ fieldPath: path18, message: "Expected a reference id" }];
4831
+ }
4832
+ return [];
4833
+ default:
4834
+ if (STRING_FIELD_TYPES.has(field.type)) {
4835
+ if (typeof value !== "string") {
4836
+ return [{ fieldPath: path18, message: "Expected text" }];
4837
+ }
4838
+ }
4839
+ return [];
4840
+ }
4841
+ }
4842
+ function isEmptyForRequired(field, value) {
4843
+ if (value === void 0 || value === null)
4844
+ return true;
4845
+ if (Array.isArray(value))
4846
+ return value.length === 0;
4847
+ if (typeof value === "string") {
4848
+ return value.trim() === "";
4849
+ }
4850
+ return false;
4851
+ }
4852
+ function validateEntryData(fields, data, pathPrefix = "") {
4853
+ const errors = [];
4854
+ for (const field of fields) {
4855
+ if (field.type === "group") {
4856
+ errors.push(...validateEntryData(field.fields, data, pathPrefix));
4857
+ continue;
4858
+ }
4859
+ const path18 = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
4860
+ const value = data[field.name];
4861
+ const isRequired = "required" in field && field.required === true;
4862
+ if (isRequired && isEmptyForRequired(field, value)) {
4863
+ const message = Array.isArray(value) ? "At least one item is required" : "This field is required";
4864
+ errors.push({ fieldPath: path18, message });
4865
+ continue;
4866
+ }
4867
+ if (value === void 0 || value === null)
4868
+ continue;
4869
+ if (field.type === "block") {
4870
+ const blockField = field;
4871
+ if (!Array.isArray(value)) {
4872
+ errors.push({ fieldPath: path18, message: "Expected a list of blocks" });
4873
+ continue;
4874
+ }
4875
+ value.forEach((item, index) => {
4876
+ const itemPath = `${path18}[${index}]`;
4877
+ if (!isPlainRecord2(item)) {
4878
+ errors.push({ fieldPath: itemPath, message: "Expected a block object" });
4879
+ return;
4880
+ }
4881
+ const resolved = resolveBlockItem(blockField, item);
4882
+ if (!resolved) {
4883
+ const template = typeof item.template === "string" ? item.template : void 0;
4884
+ errors.push({
4885
+ fieldPath: itemPath,
4886
+ message: template ? `Unknown block template "${template}"` : "Missing block template"
4887
+ });
4888
+ return;
4889
+ }
4890
+ errors.push(...validateEntryData(resolved.fields, resolved.data, itemPath));
4891
+ });
4892
+ continue;
4893
+ }
4894
+ if (field.type === "object") {
4895
+ const objectField = field;
4896
+ if (objectField.list) {
4897
+ if (!Array.isArray(value)) {
4898
+ errors.push({ fieldPath: path18, message: "Expected a list of items" });
4899
+ continue;
4900
+ }
4901
+ value.forEach((item, index) => {
4902
+ const itemPath = `${path18}[${index}]`;
4903
+ if (!isPlainRecord2(item)) {
4904
+ errors.push({ fieldPath: itemPath, message: "Expected an object" });
4905
+ return;
4906
+ }
4907
+ if (objectField.fields) {
4908
+ errors.push(...validateEntryData(objectField.fields, item, itemPath));
4909
+ }
4910
+ });
4911
+ } else if (!isPlainRecord2(value)) {
4912
+ errors.push({ fieldPath: path18, message: "Expected an object" });
4913
+ } else if (objectField.fields) {
4914
+ errors.push(...validateEntryData(objectField.fields, value, path18));
4915
+ }
4916
+ continue;
4917
+ }
4918
+ if ("list" in field && field.list) {
4919
+ if (!Array.isArray(value)) {
4920
+ errors.push({ fieldPath: path18, message: "Expected a list of values" });
4921
+ continue;
4922
+ }
4923
+ value.forEach((item, index) => {
4924
+ errors.push(...validateScalar(field, item, `${path18}[${index}]`));
4925
+ });
4926
+ continue;
4927
+ }
4928
+ if (Array.isArray(value) && KNOWN_FIELD_TYPES.has(field.type)) {
4929
+ errors.push({ fieldPath: path18, message: "Expected a single value, not a list" });
4930
+ continue;
4931
+ }
4932
+ errors.push(...validateScalar(field, value, path18));
4933
+ }
4934
+ return errors;
4935
+ }
4936
+
4937
+ // dist/static/index.js
4938
+ function normalizeDatesDeep(value) {
4939
+ if (value instanceof Date)
4940
+ return value.toISOString();
4941
+ if (Array.isArray(value))
4942
+ return value.map(normalizeDatesDeep);
4943
+ if (value && typeof value === "object") {
4944
+ const result = {};
4945
+ for (const [key, v] of Object.entries(value)) {
4946
+ result[key] = normalizeDatesDeep(v);
4947
+ }
4948
+ return result;
4949
+ }
4950
+ return value;
4951
+ }
4952
+ function findInvalidEntries(items) {
4953
+ const invalid = [];
4954
+ for (const item of items) {
4955
+ if (!item.schema)
4956
+ continue;
4957
+ const data = normalizeDatesDeep(item.data);
4958
+ const errors = validateEntryData(item.schema, data);
4959
+ if (errors.length > 0) {
4960
+ invalid.push({ entryPath: item.entryPath, errors });
4961
+ }
4962
+ }
4963
+ return invalid;
4964
+ }
4965
+ function assertBuildEntriesValid(items, phaseLabel) {
4966
+ const invalid = findInvalidEntries(items);
4967
+ if (invalid.length === 0)
4968
+ return;
4969
+ const lines = invalid.map(({ entryPath, errors }) => {
4970
+ const [first, ...rest] = errors;
4971
+ const summary = rest.length > 0 ? `${first.fieldPath}: ${first.message} (+${rest.length} more)` : `${first.fieldPath}: ${first.message}`;
4972
+ return ` - ${entryPath} \u2014 ${summary}`;
4973
+ });
4974
+ throw new Error(`CanopyCMS static build: found ${invalid.length} schema-invalid ${invalid.length === 1 ? "entry" : "entries"} during ${phaseLabel}:
4975
+ ${lines.join("\n")}
4976
+ These are likely abandoned create-scaffolds (empty entries left behind when a create was started but never finished). Finish editing the entry (fill in its required fields) or delete the abandoned draft, then rebuild.`);
4977
+ }
4978
+
4186
4979
  // dist/build/generate-ai-content.js
4187
4980
  async function generateAIContentFiles(options) {
4188
4981
  const { config, entrySchemaRegistry, outputDir, aiConfig, _testFlatSchema } = options;
@@ -4196,6 +4989,8 @@ async function generateAIContentFiles(options) {
4196
4989
  const cached = await schemaCache.getSchema(branchRoot, entrySchemaRegistry, contentRootName);
4197
4990
  flatSchema = cached.flatSchema;
4198
4991
  }
4992
+ const entriesForValidation = await listEntries(branchRoot, flatSchema, contentRootName);
4993
+ assertBuildEntriesValid(entriesForValidation, "AI content generation");
4199
4994
  const store = new ContentStore(branchRoot, flatSchema);
4200
4995
  const result = await generateAIContent({
4201
4996
  store,
@@ -4204,15 +4999,15 @@ async function generateAIContentFiles(options) {
4204
4999
  config: aiConfig,
4205
5000
  entryLinkUrl: config.entryLinkUrl
4206
5001
  });
4207
- const absoluteOutputDir = path13.resolve(outputDir) + path13.sep;
5002
+ const absoluteOutputDir = path16.resolve(outputDir) + path16.sep;
4208
5003
  let fileCount = 0;
4209
5004
  for (const [filePath, content] of result.files) {
4210
- const absolutePath = path13.resolve(path13.join(absoluteOutputDir, filePath));
5005
+ const absolutePath = path16.resolve(path16.join(absoluteOutputDir, filePath));
4211
5006
  if (!absolutePath.startsWith(absoluteOutputDir)) {
4212
5007
  throw new Error(`Path traversal detected in AI content output: ${filePath}`);
4213
5008
  }
4214
- await fs12.mkdir(path13.dirname(absolutePath), { recursive: true });
4215
- await fs12.writeFile(absolutePath, content, "utf-8");
5009
+ await fs15.mkdir(path16.dirname(absolutePath), { recursive: true });
5010
+ await fs15.writeFile(absolutePath, content, "utf-8");
4216
5011
  fileCount++;
4217
5012
  }
4218
5013
  return { fileCount, outputDir: absoluteOutputDir };
@@ -4223,7 +5018,7 @@ var jiti = createJiti(import.meta.url);
4223
5018
  async function generateAIContentCLI(options) {
4224
5019
  const { projectDir, outputDir = "public/ai", configPath, appDir = "app" } = options;
4225
5020
  console.log("\nCanopyCMS generate-ai-content\n");
4226
- const canopyConfigPath = path14.join(projectDir, "canopycms.config.ts");
5021
+ const canopyConfigPath = path17.join(projectDir, "canopycms.config.ts");
4227
5022
  let canopyConfigModule;
4228
5023
  try {
4229
5024
  canopyConfigModule = await jiti.import(canopyConfigPath);
@@ -4234,7 +5029,7 @@ async function generateAIContentCLI(options) {
4234
5029
  }
4235
5030
  const configExport = canopyConfigModule.default ?? canopyConfigModule.config ?? canopyConfigModule;
4236
5031
  const serverConfig = typeof configExport === "object" && configExport !== null && "server" in configExport ? configExport.server : configExport;
4237
- const schemasPath = path14.join(projectDir, appDir, "schemas.ts");
5032
+ const schemasPath = path17.join(projectDir, appDir, "schemas.ts");
4238
5033
  let entrySchemaRegistry = {};
4239
5034
  try {
4240
5035
  const schemasModule = await jiti.import(schemasPath);
@@ -4245,7 +5040,7 @@ async function generateAIContentCLI(options) {
4245
5040
  let aiConfig;
4246
5041
  if (configPath) {
4247
5042
  try {
4248
- const aiConfigModule = await jiti.import(path14.resolve(configPath));
5043
+ const aiConfigModule = await jiti.import(path17.resolve(configPath));
4249
5044
  aiConfig = aiConfigModule.aiContentConfig ?? aiConfigModule.default ?? aiConfigModule.config;
4250
5045
  } catch (err) {
4251
5046
  console.error(`Could not load AI config from ${configPath}`);
@@ -4262,7 +5057,7 @@ async function generateAIContentCLI(options) {
4262
5057
  console.error("Make sure canopycms.config.ts uses defineCanopyConfig().");
4263
5058
  process.exit(1);
4264
5059
  }
4265
- const resolvedOutput = path14.resolve(projectDir, outputDir);
5060
+ const resolvedOutput = path17.resolve(projectDir, outputDir);
4266
5061
  console.log(` Output: ${resolvedOutput}`);
4267
5062
  console.log(` Mode: ${serverConfig.mode ?? "dev"}`);
4268
5063
  const result = await generateAIContentFiles({