canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
@@ -9,6 +9,9 @@ var __export = (target, all) => {
9
9
  };
10
10
 
11
11
  // dist/utils/logger.js
12
+ function canopyLog(...args) {
13
+ active.log(...args);
14
+ }
12
15
  function canopyLogWarn(...args) {
13
16
  active.warn(...args);
14
17
  }
@@ -36,7 +39,7 @@ function isImplicitlyOffOrigin(url) {
36
39
  return false;
37
40
  }
38
41
  }
39
- function isHttpUrlOrSameOriginPath(value, opts = {}) {
42
+ function isHttpUrlOrSameOriginPath(value) {
40
43
  if (value === "")
41
44
  return false;
42
45
  for (let index = 0; index < value.length; index++) {
@@ -60,9 +63,8 @@ function isHttpUrlOrSameOriginPath(value, opts = {}) {
60
63
  return false;
61
64
  }
62
65
  }
63
- if (isImplicitlyOffOrigin(value)) {
64
- return opts.allowProtocolRelative === true && value.startsWith("//") && value[2] !== "/";
65
- }
66
+ if (isImplicitlyOffOrigin(value))
67
+ return false;
66
68
  return value.startsWith("/") && value[1] !== "/";
67
69
  }
68
70
  var SENTINEL_BASE, DOT_SEGMENT;
@@ -75,11 +77,11 @@ var init_sanitize_href = __esm({
75
77
  });
76
78
 
77
79
  // dist/paths/normalize.js
78
- function normalizeFilesystemPath(path22) {
79
- return path22.split(/[\\/]+/).filter(Boolean).join("/");
80
+ function normalizeFilesystemPath(path24) {
81
+ return path24.split(/[\\/]+/).filter(Boolean).join("/");
80
82
  }
81
- function hasTraversalSequence(path22) {
82
- const normalized = normalizeFilesystemPath(path22);
83
+ function hasTraversalSequence(path24) {
84
+ const normalized = normalizeFilesystemPath(path24);
83
85
  return normalized.includes("..");
84
86
  }
85
87
  function createLogicalPath(...segments) {
@@ -89,14 +91,17 @@ function createLogicalPath(...segments) {
89
91
  }
90
92
  return normalized;
91
93
  }
92
- function trimSlashes(path22) {
94
+ function entryLogicalPath(collectionLogicalPath, slug) {
95
+ return collectionLogicalPath ? `${collectionLogicalPath}/${slug}` : slug;
96
+ }
97
+ function trimSlashes(path24) {
93
98
  let start = 0;
94
- let end = path22.length;
95
- while (start < end && path22[start] === "/")
99
+ let end = path24.length;
100
+ while (start < end && path24[start] === "/")
96
101
  start++;
97
- while (end > start && path22[end - 1] === "/")
102
+ while (end > start && path24[end - 1] === "/")
98
103
  end--;
99
- return path22.slice(start, end);
104
+ return path24.slice(start, end);
100
105
  }
101
106
  var init_normalize = __esm({
102
107
  "dist/paths/normalize.js"() {
@@ -310,7 +315,8 @@ var init_field = __esm({
310
315
  });
311
316
  objectFieldSchema = fieldBaseSchema.extend({
312
317
  type: z2.literal("object"),
313
- fields: z2.array(z2.lazy(() => fieldHolder[0])).min(1)
318
+ fields: z2.array(z2.lazy(() => fieldHolder[0])).min(1),
319
+ itemTitleField: z2.string().min(1).optional()
314
320
  });
315
321
  inlineGroupFieldSchema = z2.object({
316
322
  type: z2.literal("group"),
@@ -375,7 +381,7 @@ var init_collection = __esm({
375
381
 
376
382
  // dist/config/schemas/url.js
377
383
  import { z as z4 } from "zod";
378
- var uploadTargetUrlSchema, assetMountUrlSchema;
384
+ var uploadTargetUrlSchema, previewPrefixSchema;
379
385
  var init_url = __esm({
380
386
  "dist/config/schemas/url.js"() {
381
387
  "use strict";
@@ -383,8 +389,8 @@ var init_url = __esm({
383
389
  uploadTargetUrlSchema = z4.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value), {
384
390
  message: 'must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment'
385
391
  });
386
- assetMountUrlSchema = z4.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value, { allowProtocolRelative: true }), {
387
- message: 'must be an absolute http(s) URL, a protocol-relative "//host" URL, or a site-relative path beginning with a single "/", with no query or fragment'
392
+ previewPrefixSchema = z4.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value), {
393
+ message: 'editor.previewPrefix must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment'
388
394
  });
389
395
  }
390
396
  });
@@ -400,7 +406,6 @@ var init_media = __esm({
400
406
  mediaSchema = z5.discriminatedUnion("adapter", [
401
407
  z5.object({
402
408
  adapter: z5.literal("local"),
403
- publicBaseUrl: assetMountUrlSchema.optional(),
404
409
  /** Root directory for local asset storage. Defaults to the caller's dev-assets dir. */
405
410
  directory: relativePathSchema.optional()
406
411
  }).strict(),
@@ -408,7 +413,6 @@ var init_media = __esm({
408
413
  adapter: z5.literal("s3"),
409
414
  bucket: z5.string().min(1),
410
415
  region: z5.string().min(1),
411
- publicBaseUrl: assetMountUrlSchema.optional(),
412
416
  /**
413
417
  * Where the browser POSTs a presigned direct upload. Absolute http(s) URL or a
414
418
  * site-relative path; defaults to the S3 REST endpoint the AWS SDK returns.
@@ -418,18 +422,17 @@ var init_media = __esm({
418
422
  * signature is unaffected — a presigned POST's string-to-sign is the base64 policy
419
423
  * alone, so the host never enters it (pinned by store-s3.test.ts).
420
424
  *
421
- * NOT a prefix. Unlike `publicBaseUrl`, nothing is joined onto this value; it replaces
422
- * the POST target outright and is passed through byte-for-byte. Do not route it through
423
- * `joinUrlPrefix` "for consistency" — that would strip the trailing slash and silently
424
- * stop a CDN path pattern like `/asset-upload/*` from matching.
425
+ * NOT a prefix: nothing is joined onto this value; it replaces the POST target outright
426
+ * and is passed through byte-for-byte. Do not route it through `joinUrlPrefix` "for
427
+ * consistency" — that would strip the trailing slash and silently stop a CDN path
428
+ * pattern like `/asset-upload/*` from matching.
425
429
  */
426
430
  uploadUrl: uploadTargetUrlSchema.optional(),
427
431
  /** Max upload size in bytes for presigned direct uploads. Defaults to 50 MiB. */
428
432
  maxUploadBytes: z5.number().int().positive().optional()
429
433
  }).strict(),
430
434
  z5.object({
431
- adapter: z5.literal("lfs"),
432
- publicBaseUrl: assetMountUrlSchema.optional()
435
+ adapter: z5.literal("lfs")
433
436
  }).strict()
434
437
  ]);
435
438
  }
@@ -443,6 +446,7 @@ var init_config = __esm({
443
446
  "use strict";
444
447
  init_collection();
445
448
  init_media();
449
+ init_url();
446
450
  defaultBranchAccessSchema = z6.enum(["allow", "deny"]).default("deny");
447
451
  defaultPathAccessLevelSchema = z6.enum(["allow", "deny"]);
448
452
  defaultPathAccessLevelsSchema = z6.object({
@@ -473,7 +477,8 @@ var init_config = __esm({
473
477
  title: z6.string().optional(),
474
478
  subtitle: z6.string().optional(),
475
479
  theme: z6.unknown().optional(),
476
- previewBase: z6.record(z6.string()).optional(),
480
+ previewBase: z6.record(z6.union([z6.string(), z6.literal(false)])).optional(),
481
+ previewPrefix: previewPrefixSchema.optional(),
477
482
  // UI handler functions (runtime only, don't serialize)
478
483
  onAccountClick: z6.function().returns(z6.void()).optional(),
479
484
  onLogoutClick: z6.function().returns(z6.void()).optional(),
@@ -501,6 +506,9 @@ var init_config = __esm({
501
506
  defaultRemoteUrl: defaultRemoteUrlSchema.optional(),
502
507
  gitBotAuthorName: gitBotAuthorNameSchema,
503
508
  gitBotAuthorEmail: gitBotAuthorEmailSchema,
509
+ // Unset reads as on for Edited-by and off for Co-authored-by (services.ts submitBranch).
510
+ gitEditedByTrailers: z6.boolean().optional(),
511
+ gitCoAuthoredByTrailers: z6.boolean().optional(),
504
512
  githubTokenEnvVar: githubTokenEnvVarSchema.optional(),
505
513
  // Required by design (follow-up to SEC-C1): a prod deploy that omits `mode` must fail
506
514
  // validation loudly rather than silently running header-trusting dev auth semantics.
@@ -512,7 +520,6 @@ var init_config = __esm({
512
520
  // Default false/unset — the standard AWS Lambda+worker topology must leave this unset.
513
521
  allowNetworkRemoteInProd: z6.boolean().optional(),
514
522
  settingsBranch: z6.string().optional(),
515
- autoCreateSettingsPR: z6.boolean().optional(),
516
523
  // `.optional()`, NOT bare `deploymentNameSchema`: its own `.default('prod')` would make
517
524
  // `parse(undefined)` resolve to 'prod' instead of staying `undefined`, collapsing the
518
525
  // env > config > modeDefault precedence chain `resolveDeploymentName`
@@ -528,7 +535,7 @@ var init_config = __esm({
528
535
  validateEntry: z6.custom().optional(),
529
536
  dev: devConfigSchema.optional()
530
537
  }).strict();
531
- DEFAULT_PROD_WORKSPACE = "/mnt/efs/workspace";
538
+ DEFAULT_PROD_WORKSPACE = "/mnt/efs";
532
539
  }
533
540
  });
534
541
 
@@ -574,20 +581,20 @@ var init_validation = __esm({
574
581
  init_config();
575
582
  init_flatten();
576
583
  init_mode_env();
577
- forEachReferenceField = (fields, visit) => {
584
+ forEachReferenceField = (fields, visit2) => {
578
585
  if (!Array.isArray(fields))
579
586
  return;
580
587
  for (const field of fields) {
581
588
  const f = field;
582
589
  if (f?.type === "reference") {
583
- visit(f);
590
+ visit2(f);
584
591
  }
585
592
  if (f?.type === "group" || f?.type === "object") {
586
- forEachReferenceField(f.fields, visit);
593
+ forEachReferenceField(f.fields, visit2);
587
594
  }
588
595
  if (f?.type === "block" && Array.isArray(f.templates)) {
589
596
  for (const template of f.templates) {
590
- forEachReferenceField(template.fields, visit);
597
+ forEachReferenceField(template.fields, visit2);
591
598
  }
592
599
  }
593
600
  }
@@ -835,9 +842,6 @@ var init_client_unsafe_strategy = __esm({
835
842
  throw new Error("gitBotAuthorName and gitBotAuthorEmail are required in prod mode");
836
843
  }
837
844
  }
838
- shouldCreateSettingsPR(config) {
839
- return config.autoCreateSettingsPR ?? true;
840
- }
841
845
  };
842
846
  DevStrategy = class extends DevClientSafeStrategy {
843
847
  getWorkspaceRoot(sourceRoot) {
@@ -884,9 +888,6 @@ var init_client_unsafe_strategy = __esm({
884
888
  }
885
889
  validateConfig(_config) {
886
890
  }
887
- shouldCreateSettingsPR(_config) {
888
- return false;
889
- }
890
891
  };
891
892
  strategyCache = /* @__PURE__ */ new Map();
892
893
  }
@@ -914,12 +915,12 @@ var init_operating_mode = __esm({
914
915
  });
915
916
 
916
917
  // dist/cli/generate-ai-content.js
917
- import path21 from "node:path";
918
+ import path23 from "node:path";
918
919
  import { createJiti } from "jiti";
919
920
 
920
921
  // dist/build/generate-ai-content.js
921
- import fs18 from "node:fs/promises";
922
- import path20 from "node:path";
922
+ import fs20 from "node:fs/promises";
923
+ import path22 from "node:path";
923
924
 
924
925
  // dist/utils/error.js
925
926
  function getErrorMessage(err) {
@@ -931,6 +932,15 @@ function getErrorMessage(err) {
931
932
  }
932
933
  return String(err);
933
934
  }
935
+ function redactCredentials(message) {
936
+ let result = message;
937
+ result = result.replace(/(:\/\/)[^/\s@]+@/g, "$1***@");
938
+ result = result.replace(/\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{8,}/g, "***");
939
+ result = result.replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/g, "$1***");
940
+ result = result.replace(/-----BEGIN [A-Z]{0,9} ?PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z]{0,9} ?PRIVATE KEY-----|$)/g, "<private-key>");
941
+ result = result.replace(/(?<![\w-])eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]+/g, "***");
942
+ return result;
943
+ }
934
944
  function isNodeError(err) {
935
945
  return err instanceof Error && "code" in err;
936
946
  }
@@ -942,7 +952,7 @@ function isFileExistsError(err) {
942
952
  }
943
953
 
944
954
  // dist/content-store.js
945
- import fs10 from "node:fs/promises";
955
+ import fs9 from "node:fs/promises";
946
956
  import path11 from "node:path";
947
957
  import matter2 from "gray-matter";
948
958
  import { parse as yamlParse } from "yaml";
@@ -965,8 +975,9 @@ async function atomicWriteFile(filePath, content) {
965
975
  }
966
976
 
967
977
  // dist/utils/content-serialize.js
978
+ import { isDeepStrictEqual as isDeepStrictEqual2 } from "node:util";
968
979
  import matter from "gray-matter";
969
- import { isCollection, isMap, isNode, isScalar, isSeq, parseDocument, stringify as yamlStringify } from "yaml";
980
+ import { isCollection as isCollection2, isMap as isMap2, isNode as isNode2, isScalar as isScalar2, isSeq as isSeq2, parseDocument as parseDocument2, Scalar, stringify as yamlStringify } from "yaml";
970
981
 
971
982
  // dist/validation/block-structural-keys.js
972
983
  var BLOCK_DISCRIMINATOR_KEYS = ["template", "_type"];
@@ -975,7 +986,517 @@ function isBlockStructuralKey(key) {
975
986
  return BLOCK_STRUCTURAL_KEYS.has(key);
976
987
  }
977
988
 
989
+ // dist/utils/debug.js
990
+ var LOG_LEVELS = {
991
+ DEBUG: 0,
992
+ INFO: 1,
993
+ WARN: 2,
994
+ ERROR: 3
995
+ };
996
+ var DebugLogger = class {
997
+ constructor(options = {}) {
998
+ this.timers = /* @__PURE__ */ new Map();
999
+ this.options = options;
1000
+ }
1001
+ shouldLog(level) {
1002
+ const enabled = this.options.enabled ?? process.env.CANOPYCMS_DEBUG === "true";
1003
+ if (!enabled)
1004
+ return false;
1005
+ const minLevel = this.options.minLevel ?? "DEBUG";
1006
+ return LOG_LEVELS[level] >= LOG_LEVELS[minLevel];
1007
+ }
1008
+ /**
1009
+ * The ISO-8601 timestamp leads the line BARE, never bracketed. `CANOPYCMS_DEBUG=true` can
1010
+ * be set on the EC2 worker, whose stdout appends to /var/log/canopy-worker/worker.log, and
1011
+ * the CloudWatch agent's `multi_line_start_pattern` matches a timestamp at the START of a
1012
+ * line (see worker/log.ts's INVARIANT and cms-service.ts's agent config): any leading
1013
+ * character folds the line into the previous event instead of starting its own.
1014
+ */
1015
+ formatMessage(level, category, message) {
1016
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1017
+ const prefix = this.options.prefix ?? "CanopyCMS";
1018
+ return `${timestamp} [${prefix}:${category}] [${level}] ${message}`;
1019
+ }
1020
+ debug(category, message, data) {
1021
+ if (this.shouldLog("DEBUG")) {
1022
+ console.log(this.formatMessage("DEBUG", category, message), data ?? "");
1023
+ }
1024
+ }
1025
+ info(category, message, data) {
1026
+ if (this.shouldLog("INFO")) {
1027
+ console.log(this.formatMessage("INFO", category, message), data ?? "");
1028
+ }
1029
+ }
1030
+ warn(category, message, data) {
1031
+ if (this.shouldLog("WARN")) {
1032
+ console.warn(this.formatMessage("WARN", category, message), data ?? "");
1033
+ }
1034
+ }
1035
+ error(category, message, data) {
1036
+ const msg = this.formatMessage("ERROR", category, message);
1037
+ if (this.shouldLog("ERROR")) {
1038
+ console.error(msg, data ?? "");
1039
+ }
1040
+ const throwOnError = this.options.throwOnError ?? false;
1041
+ if (throwOnError) {
1042
+ const errorMsg = data ? `${message}: ${JSON.stringify(data)}` : message;
1043
+ throw new Error(errorMsg);
1044
+ }
1045
+ }
1046
+ /**
1047
+ * Keyed by label alone, so two overlapping timers with one label overwrite each other;
1048
+ * anything that can run concurrently uses {@link timed}.
1049
+ */
1050
+ time(label) {
1051
+ this.timers.set(label, Date.now());
1052
+ }
1053
+ timeEnd(category, label) {
1054
+ const start = this.timers.get(label);
1055
+ if (start === void 0) {
1056
+ this.warn(category, `Timer '${label}' does not exist`);
1057
+ return;
1058
+ }
1059
+ const duration = Date.now() - start;
1060
+ this.timers.delete(label);
1061
+ this.debug(category, `${label} completed`, { durationMs: duration });
1062
+ return duration;
1063
+ }
1064
+ /**
1065
+ * Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
1066
+ * call, so concurrent spans with the same label on one logger never overwrite each other.
1067
+ */
1068
+ async timed(category, label, fn) {
1069
+ const start = Date.now();
1070
+ try {
1071
+ return await fn();
1072
+ } finally {
1073
+ this.debug(category, `${label} completed`, { durationMs: Date.now() - start });
1074
+ }
1075
+ }
1076
+ };
1077
+ function createDebugLogger(options) {
1078
+ return new DebugLogger(options);
1079
+ }
1080
+ var testLogger = createDebugLogger({
1081
+ enabled: process.env.E2E_DEBUG === "true",
1082
+ prefix: "E2E",
1083
+ throwOnError: false
1084
+ });
1085
+
1086
+ // dist/utils/yaml-source-splice.js
1087
+ import { isDeepStrictEqual } from "node:util";
1088
+ import { Document, parseDocument, isAlias, isCollection, isMap, isNode, isPair, isScalar, isSeq, YAMLMap, visit, YAMLSeq } from "yaml";
1089
+ var END = Symbol("end");
1090
+ var FRESH = Symbol("fresh");
1091
+ var LINE_WIDTH = 80;
1092
+ var MIN_LINE_WIDTH = 40;
1093
+ function snapshotDocument(doc) {
1094
+ const items = /* @__PURE__ */ new Map();
1095
+ const visitNode = (node) => {
1096
+ if (isMap(node)) {
1097
+ items.set(node, node.items.map((pair) => ({ item: pair, value: pair.value })));
1098
+ for (const pair of node.items)
1099
+ visitNode(pair.value);
1100
+ } else if (isSeq(node)) {
1101
+ items.set(node, node.items.map((item) => ({ item, value: item })));
1102
+ for (const item of node.items)
1103
+ visitNode(item);
1104
+ }
1105
+ };
1106
+ visitNode(doc.contents);
1107
+ let anchored = false;
1108
+ visit(doc, (_key, node) => {
1109
+ if (isAlias(node) || isNode(node) && node.anchor !== void 0) {
1110
+ anchored = true;
1111
+ return visit.BREAK;
1112
+ }
1113
+ return void 0;
1114
+ });
1115
+ return { items, anchored };
1116
+ }
1117
+ var SourceSplicer = class {
1118
+ constructor(raw, eol, doc, snapshot, replaced) {
1119
+ this.raw = raw;
1120
+ this.eol = eol;
1121
+ this.doc = doc;
1122
+ this.snapshot = snapshot;
1123
+ this.replaced = replaced;
1124
+ this.dirtyMemo = /* @__PURE__ */ new Map();
1125
+ }
1126
+ print() {
1127
+ const root = this.doc.contents;
1128
+ if (!isCollection(root) || !this.snapshot.items.has(root))
1129
+ return void 0;
1130
+ if (!this.isDirty(root))
1131
+ return this.raw;
1132
+ if (this.snapshot.anchored)
1133
+ return void 0;
1134
+ const edits = this.collectionEdits(root);
1135
+ return edits === void 0 ? void 0 : this.applyEdits(0, this.raw.length, edits);
1136
+ }
1137
+ /**
1138
+ * Did the reconciler change anything under this node? An original scalar is never mutated in
1139
+ * place (a changed value gets a fresh node), so only collections can be dirty.
1140
+ */
1141
+ isDirty(node) {
1142
+ if (!isCollection(node))
1143
+ return false;
1144
+ const memo = this.dirtyMemo.get(node);
1145
+ if (memo !== void 0)
1146
+ return memo;
1147
+ const snap = this.snapshot.items.get(node);
1148
+ let dirty = snap === void 0 || snap.length !== node.items.length;
1149
+ for (let i = 0; !dirty && snap !== void 0 && i < snap.length; i++) {
1150
+ const now = node.items[i];
1151
+ const before = snap[i];
1152
+ if (now !== before.item)
1153
+ dirty = true;
1154
+ else if (isPair(now))
1155
+ dirty = now.value !== before.value || this.isDirty(now.value);
1156
+ else
1157
+ dirty = this.isDirty(now);
1158
+ }
1159
+ this.dirtyMemo.set(node, dirty);
1160
+ return dirty;
1161
+ }
1162
+ collectionEdits(node) {
1163
+ if (node.flow)
1164
+ return void 0;
1165
+ const snap = this.snapshot.items.get(node);
1166
+ if (snap === void 0 || snap.length === 0 || node.items.length === 0)
1167
+ return void 0;
1168
+ const geometry = [];
1169
+ for (const { item, value } of snap) {
1170
+ const g = isPair(item) ? this.pairGeometry(item, value) : this.seqItemGeometry(item);
1171
+ if (g === void 0)
1172
+ return void 0;
1173
+ geometry.push(g);
1174
+ }
1175
+ if (!this.isBlockLayout(geometry))
1176
+ return void 0;
1177
+ return isMap(node) ? this.mapEdits(node, snap, geometry) : this.seqEdits(node, snap, geometry);
1178
+ }
1179
+ mapEdits(map, snap, geometry) {
1180
+ const col = this.column(geometry[0].content);
1181
+ const original = new Set(snap.map((s) => s.item));
1182
+ const kept = new Set(map.items);
1183
+ const edits = [];
1184
+ const retainedOrder = map.items.filter((pair) => original.has(pair));
1185
+ const expectedOrder = snap.map((s) => s.item).filter((pair) => kept.has(pair));
1186
+ if (retainedOrder.some((pair, i) => pair !== expectedOrder[i]))
1187
+ return void 0;
1188
+ const added = map.items.slice(retainedOrder.length);
1189
+ if (added.some((pair) => original.has(pair)))
1190
+ return void 0;
1191
+ const layout = [...geometry];
1192
+ for (let i = 0; i < snap.length; i++) {
1193
+ const { item, value: before } = snap[i];
1194
+ const g = layout[i];
1195
+ if (!isPair(item))
1196
+ return void 0;
1197
+ if (!kept.has(item)) {
1198
+ if (g.inline) {
1199
+ const next = this.promoteToInline(snap, layout, kept);
1200
+ if (next === void 0)
1201
+ return void 0;
1202
+ edits.push({ start: g.content, end: layout[next].content, text: "" });
1203
+ i = next - 1;
1204
+ } else {
1205
+ const start = i === 0 ? this.ownLeadingLines(map, item, g.lineStart) : this.chunkStart(layout, i);
1206
+ edits.push({ start, end: g.end, text: "" });
1207
+ }
1208
+ continue;
1209
+ }
1210
+ const now = item.value;
1211
+ const clean = now === before && !this.isDirty(now);
1212
+ const inner = clean ? [] : now === before && isCollection(now) ? this.collectionEdits(now) : void 0;
1213
+ if (inner !== void 0) {
1214
+ const next = map.items[map.items.indexOf(item) + 1] ?? END;
1215
+ if ((i + 1 < snap.length ? snap[i + 1].item : END) !== next) {
1216
+ edits.push(...uncovered(this.outdentedCommentEdits(g, col), inner));
1217
+ }
1218
+ edits.push(...inner);
1219
+ continue;
1220
+ }
1221
+ const text = withoutLeadingComment(item.key, () => this.render(mapOf([item]), col, g.inline));
1222
+ edits.push({ start: g.lineStart, end: g.end, text: this.matchFinalNewline(g.end, text) });
1223
+ }
1224
+ if (added.length > 0) {
1225
+ const at = layout[layout.length - 1].end;
1226
+ const text = this.render(mapOf(added), col, false);
1227
+ edits.push({ start: at, end: at, text: this.raw[at - 1] === "\n" ? text : this.eol + text });
1228
+ }
1229
+ return edits;
1230
+ }
1231
+ seqEdits(seq, snap, geometry) {
1232
+ if (geometry[0].inline)
1233
+ return void 0;
1234
+ const col = this.column(geometry[0].content);
1235
+ const oldIndex = new Map(snap.map((s, i) => [s.item, i]));
1236
+ const parts = [];
1237
+ const sourceOf = (item) => oldIndex.has(item) ? item : isNode(item) ? this.replaced.get(item) : void 0;
1238
+ for (const [k, item] of seq.items.entries()) {
1239
+ const j = oldIndex.get(item);
1240
+ let part;
1241
+ if (j !== void 0) {
1242
+ const start2 = this.chunkStart(geometry, j);
1243
+ const inner = isCollection(item) && this.isDirty(item) ? this.collectionEdits(item) : [];
1244
+ const next = k + 1 < seq.items.length ? sourceOf(seq.items[k + 1]) : END;
1245
+ const moved = (j + 1 < snap.length ? snap[j + 1].item : END) !== (next ?? FRESH);
1246
+ part = inner !== void 0 ? this.applyEdits(start2, geometry[j].end, [
1247
+ ...inner,
1248
+ ...moved ? uncovered(this.outdentedCommentEdits(geometry[j], col), inner) : []
1249
+ ]) : this.raw.slice(start2, geometry[j].lineStart) + withoutLeadingComment(item, () => this.render(seqOf(item), col, false));
1250
+ } else {
1251
+ const old = isNode(item) ? this.replaced.get(item) : void 0;
1252
+ const k2 = old === void 0 ? void 0 : oldIndex.get(old);
1253
+ part = k2 !== void 0 && sameLeadingComment(old, item) ? this.raw.slice(this.chunkStart(geometry, k2), geometry[k2].lineStart) + withoutLeadingComment(item, () => this.render(seqOf(item), col, false)) : this.render(seqOf(item), col, false);
1254
+ }
1255
+ parts.push(part.endsWith("\n") ? part : part + this.eol);
1256
+ }
1257
+ const start = geometry[0].lineStart;
1258
+ const end = geometry[geometry.length - 1].end;
1259
+ return [{ start, end, text: this.matchFinalNewline(end, parts.join("")) }];
1260
+ }
1261
+ /** Rendered text always ends in a line break; drop it where the source it replaces had none. */
1262
+ matchFinalNewline(end, text) {
1263
+ return this.raw[end - 1] !== "\n" && text.endsWith(this.eol) ? text.slice(0, -this.eol.length) : text;
1264
+ }
1265
+ /**
1266
+ * The first pair shares the parent's `- ` line and was dropped: move the first retained pair up
1267
+ * onto that line, so the pairs before it go and the rest of the map stays as written. Returns
1268
+ * that pair's index, or undefined when no pair is retained or comment lines above it would have
1269
+ * to go with the lines being removed.
1270
+ */
1271
+ promoteToInline(snap, layout, kept) {
1272
+ const next = snap.findIndex((s) => kept.has(s.item));
1273
+ if (next <= 0)
1274
+ return void 0;
1275
+ if (this.raw.slice(this.chunkStart(layout, next), layout[next].lineStart).includes("#")) {
1276
+ return void 0;
1277
+ }
1278
+ const g = layout[next];
1279
+ layout[next] = { ...g, inline: true, lineStart: g.content };
1280
+ return next;
1281
+ }
1282
+ /**
1283
+ * Where a dropped FIRST pair's lines begin. The comment lines above a nested collection's first
1284
+ * item are the collection's and stay, but above the root map's first key `yaml` gives them to
1285
+ * the key, so the reconciler drops them with it and so must the splice.
1286
+ */
1287
+ ownLeadingLines(map, pair, start) {
1288
+ const key = pair.key;
1289
+ if (map !== this.doc.contents || !key.commentBefore || this.doc.commentBefore)
1290
+ return start;
1291
+ let at = start;
1292
+ while (at > 0) {
1293
+ const previous = this.lineStart(at - 1);
1294
+ if (previous >= at)
1295
+ break;
1296
+ if (!/^[ \t]*(#.*)?\r?\n?$/.test(this.raw.slice(previous, at)))
1297
+ break;
1298
+ at = previous;
1299
+ }
1300
+ return at;
1301
+ }
1302
+ /**
1303
+ * The run of comment lines that ends an item's region reads to a person as being about what
1304
+ * comes next, but it is the item's: `yaml`'s parser (`pop()`) keeps a trailing run with the
1305
+ * nested collection it ends when that collection is not at column 0 and any line of the run is
1306
+ * at or right of its indent. While the neighbour below is unchanged that is harmless; once it
1307
+ * changes, indent the run's shallower lines to its deepest comment, so they read as the item's
1308
+ * and do not head content they were never about. Where that lands a line in a deeper scalar's comment or content
1309
+ * instead, {@link printsAs} refuses the splice. A run at or left of the item's own column is a
1310
+ * zero-indented list's tail, and is left alone.
1311
+ */
1312
+ outdentedCommentEdits(g, col) {
1313
+ let run = [];
1314
+ let at = this.raw.indexOf("\n", g.content) + 1;
1315
+ while (at > 0 && at < g.end) {
1316
+ const lineEnd = this.raw.indexOf("\n", at);
1317
+ const line = this.raw.slice(at, lineEnd === -1 ? g.end : lineEnd);
1318
+ const indent = line.length - line.trimStart().length;
1319
+ if (line.trimStart().startsWith("#"))
1320
+ run.push({ start: at, indent });
1321
+ else if (line.trim() !== "")
1322
+ run = [];
1323
+ at = lineEnd === -1 ? g.end : lineEnd + 1;
1324
+ }
1325
+ const pin = Math.max(-1, ...run.map((line) => line.indent));
1326
+ if (pin <= col)
1327
+ return [];
1328
+ return run.filter((line) => line.indent < pin).map((line) => ({ start: line.start, end: line.start + line.indent, text: " ".repeat(pin) }));
1329
+ }
1330
+ /** Where item `i`'s chunk begins: its own line for the first item, else the previous item's end. */
1331
+ chunkStart(geometry, i) {
1332
+ return i === 0 ? geometry[0].lineStart : geometry[i - 1].end;
1333
+ }
1334
+ /**
1335
+ * Every item starts at the same column, in order, and only the first may share its line with
1336
+ * the parent's `- ` (a compact `- key: value`). Anything else — an explicit `? key`, a
1337
+ * multi-line key, a node property on its own line — is not a layout this splices.
1338
+ */
1339
+ isBlockLayout(geometry) {
1340
+ const col = this.column(geometry[0].content);
1341
+ let previousEnd = -1;
1342
+ for (const [i, g] of geometry.entries()) {
1343
+ if (this.column(g.content) !== col)
1344
+ return false;
1345
+ if (i > 0 && g.inline)
1346
+ return false;
1347
+ if (g.lineStart < previousEnd || g.end <= g.content)
1348
+ return false;
1349
+ previousEnd = g.end;
1350
+ }
1351
+ return true;
1352
+ }
1353
+ /** `value` is the pair's value as parsed: the reconciler may already have replaced it. */
1354
+ pairGeometry(pair, value) {
1355
+ const key = pair.key;
1356
+ if (!isScalar(key) || key.range == null)
1357
+ return void 0;
1358
+ const content = key.range[0];
1359
+ const range = isNode(value) && value.range != null ? value.range : key.range;
1360
+ return this.geometry(content, range[1], range[2]);
1361
+ }
1362
+ seqItemGeometry(item) {
1363
+ if (!isNode(item) || item.range == null)
1364
+ return void 0;
1365
+ let dash = item.range[0] - 1;
1366
+ while (dash >= 0 && " \r\n".includes(this.raw[dash]))
1367
+ dash--;
1368
+ if (dash < 0 || this.raw[dash] !== "-")
1369
+ return void 0;
1370
+ return this.geometry(dash, item.range[1], item.range[2]);
1371
+ }
1372
+ /**
1373
+ * Trailing blank lines are not the item's — `yaml` gives them to whatever follows — unless they
1374
+ * are inside the value itself, as a keep-chomped (`|+`) block scalar's are. A node that ends in
1375
+ * a comment can end inside the NEXT line's indentation; that line is not the item's either.
1376
+ */
1377
+ geometry(content, valueEnd, nodeEnd) {
1378
+ const nodeEndLine = this.lineStart(nodeEnd);
1379
+ if (nodeEndLine > content && /^[ \t]*$/.test(this.raw.slice(nodeEndLine, nodeEnd))) {
1380
+ nodeEnd = nodeEndLine;
1381
+ valueEnd = Math.min(valueEnd, nodeEnd);
1382
+ }
1383
+ let last = Math.max(content, nodeEnd - 1);
1384
+ while (last > content && last >= valueEnd && " \r\n".includes(this.raw[last]))
1385
+ last--;
1386
+ const newline = this.raw.indexOf("\n", last);
1387
+ const lineStart = this.lineStart(content);
1388
+ const onOwnLine = /^ *$/.test(this.raw.slice(lineStart, content));
1389
+ return {
1390
+ content,
1391
+ inline: !onOwnLine,
1392
+ lineStart: onOwnLine ? lineStart : content,
1393
+ end: newline === -1 ? this.raw.length : newline + 1
1394
+ };
1395
+ }
1396
+ lineStart(offset) {
1397
+ return offset <= 0 ? 0 : this.raw.lastIndexOf("\n", offset - 1) + 1;
1398
+ }
1399
+ column(offset) {
1400
+ return offset - this.lineStart(offset);
1401
+ }
1402
+ applyEdits(start, end, edits) {
1403
+ const sorted = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
1404
+ let out = "";
1405
+ let cursor = start;
1406
+ for (const edit of sorted) {
1407
+ if (edit.start < cursor || edit.end > end)
1408
+ throw new Error("overlapping source edits");
1409
+ out += this.raw.slice(cursor, edit.start) + edit.text;
1410
+ cursor = edit.end;
1411
+ }
1412
+ return out + this.raw.slice(cursor, end);
1413
+ }
1414
+ /**
1415
+ * Render `items` as a block collection whose items sit at column `col`. `inline` leaves the
1416
+ * first line unindented, for a slot that continues the parent's `- ` line. The fold width is
1417
+ * reduced by the indent so the lines come out as wide as `toString()` would make them there.
1418
+ */
1419
+ render(container, col, inline) {
1420
+ const fragment = new Document();
1421
+ fragment.schema = this.doc.schema;
1422
+ fragment.contents = container;
1423
+ const text = fragment.toString({ lineWidth: Math.max(MIN_LINE_WIDTH, LINE_WIDTH - col) }).replace(/\r\n?/g, "\n");
1424
+ const indent = " ".repeat(col);
1425
+ const lines = text.split("\n");
1426
+ if (lines[lines.length - 1] === "")
1427
+ lines.pop();
1428
+ return lines.map((line, i) => line === "" || i === 0 && inline ? line : indent + line).map((line) => line + this.eol).join("");
1429
+ }
1430
+ };
1431
+ function uncovered(extra, edits) {
1432
+ return extra.filter((e) => !edits.some((x) => x.end > x.start && x.start <= e.start && e.end <= x.end));
1433
+ }
1434
+ function mapOf(pairs) {
1435
+ const map = new YAMLMap();
1436
+ map.items = [...pairs];
1437
+ return map;
1438
+ }
1439
+ function seqOf(item) {
1440
+ const seq = new YAMLSeq();
1441
+ seq.items = [item];
1442
+ return seq;
1443
+ }
1444
+ function sameLeadingComment(a, b) {
1445
+ if (!isNode(a) || !isNode(b))
1446
+ return false;
1447
+ const x = a;
1448
+ const y = b;
1449
+ return (x.commentBefore ?? void 0) === (y.commentBefore ?? void 0) && !!x.spaceBefore === !!y.spaceBefore;
1450
+ }
1451
+ function withoutLeadingComment(node, fn) {
1452
+ if (!isNode(node))
1453
+ return fn();
1454
+ const target = node;
1455
+ const { commentBefore, spaceBefore } = target;
1456
+ target.commentBefore = void 0;
1457
+ target.spaceBefore = false;
1458
+ try {
1459
+ return fn();
1460
+ } finally {
1461
+ target.commentBefore = commentBefore;
1462
+ target.spaceBefore = spaceBefore;
1463
+ }
1464
+ }
1465
+ function lineEndingOf(raw) {
1466
+ const crlf = (raw.match(/\r\n/g) ?? []).length;
1467
+ const lf = (raw.match(/\n/g) ?? []).length;
1468
+ const cr = (raw.match(/\r/g) ?? []).length;
1469
+ if (crlf === 0)
1470
+ return cr === 0 ? "\n" : void 0;
1471
+ return crlf === lf && crlf === cr ? "\r\n" : void 0;
1472
+ }
1473
+ function withSourceLineEndings(printed, raw) {
1474
+ const text = printed.replace(/\r\n?/g, "\n");
1475
+ return lineEndingOf(raw) === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
1476
+ }
1477
+ function spliceSource(raw, doc, snapshot, replaced, printed) {
1478
+ const eol = lineEndingOf(raw);
1479
+ if (eol === void 0)
1480
+ return void 0;
1481
+ const spliced = new SourceSplicer(raw, eol, doc, snapshot, replaced).print();
1482
+ return spliced !== void 0 && printsAs(spliced, doc, printed) ? spliced : void 0;
1483
+ }
1484
+ function printsAs(candidate, reconciled, printed) {
1485
+ if (candidate === printed)
1486
+ return true;
1487
+ const reparsed = parseDocument(candidate);
1488
+ if (reparsed.errors.length > 0)
1489
+ return false;
1490
+ if (!isDeepStrictEqual(reparsed.toJS(), reconciled.toJS()))
1491
+ return false;
1492
+ return layoutFree(reparsed.toString()) === layoutFree(parseDocument(printed).toString());
1493
+ }
1494
+ function layoutFree(text) {
1495
+ return text.split("\n").map((line) => line.replace(/[ \t\r]+$/, "")).filter((line) => line !== "").join("\n");
1496
+ }
1497
+
978
1498
  // dist/utils/content-serialize.js
1499
+ var log = createDebugLogger({ prefix: "ContentSerialize" });
979
1500
  function isPlainRecord(value) {
980
1501
  if (typeof value !== "object" || value === null || Array.isArray(value))
981
1502
  return false;
@@ -983,7 +1504,7 @@ function isPlainRecord(value) {
983
1504
  return proto === Object.prototype || proto === null;
984
1505
  }
985
1506
  function recordKeyOf(keyNode) {
986
- if (!isScalar(keyNode))
1507
+ if (!isScalar2(keyNode))
987
1508
  return void 0;
988
1509
  const { value } = keyNode;
989
1510
  if (value === null || value === void 0)
@@ -993,7 +1514,7 @@ function recordKeyOf(keyNode) {
993
1514
  return String(value);
994
1515
  }
995
1516
  function carryComments(from, to) {
996
- if (!isNode(from) || !isNode(to))
1517
+ if (!isNode2(from) || !isNode2(to))
997
1518
  return;
998
1519
  const source = from;
999
1520
  const target = to;
@@ -1012,7 +1533,7 @@ function identityKey(value) {
1012
1533
  }
1013
1534
  }
1014
1535
  function nodePlainValue(node) {
1015
- if (!isNode(node))
1536
+ if (!isNode2(node))
1016
1537
  return node;
1017
1538
  try {
1018
1539
  return node.toJSON();
@@ -1022,7 +1543,7 @@ function nodePlainValue(node) {
1022
1543
  }
1023
1544
  function nodeIdentityKey(node) {
1024
1545
  const plain = nodePlainValue(node);
1025
- return plain === void 0 && isNode(node) ? void 0 : identityKey(plain);
1546
+ return plain === void 0 && isNode2(node) ? void 0 : identityKey(plain);
1026
1547
  }
1027
1548
  var EVIDENCE_MAX_DEPTH = 6;
1028
1549
  function sharesFieldEvidence(existing, value, depth) {
@@ -1048,23 +1569,46 @@ function looksLikeSameItem(node, value) {
1048
1569
  return true;
1049
1570
  return sharesFieldEvidence(existing, value, 0);
1050
1571
  }
1051
- function reconcileNode(doc, existing, value) {
1052
- if (isMap(existing) && isPlainRecord(value)) {
1053
- reconcileMap(doc, existing, value);
1572
+ function reconcileNode(ctx, existing, value) {
1573
+ if (isMap2(existing) && isPlainRecord(value)) {
1574
+ reconcileMap(ctx, existing, value);
1054
1575
  return existing;
1055
1576
  }
1056
- if (isSeq(existing) && Array.isArray(value)) {
1057
- reconcileSeq(doc, existing, value);
1577
+ if (isSeq2(existing) && Array.isArray(value)) {
1578
+ reconcileSeq(ctx, existing, value);
1058
1579
  return existing;
1059
1580
  }
1060
- if (isScalar(existing) && Object.is(existing.value, value))
1581
+ if (isScalar2(existing) && Object.is(existing.value, value))
1061
1582
  return existing;
1062
- const fresh = doc.createNode(value);
1063
- if (!isCollection(existing))
1583
+ const fresh = ctx.doc.createNode(value);
1584
+ if (isNode2(existing))
1585
+ ctx.replaced.set(fresh, existing);
1586
+ if (isScalar2(existing) && isScalar2(fresh) && typeof value === "string") {
1587
+ const style = carriedStyle(existing, value);
1588
+ if (style !== void 0) {
1589
+ fresh.type = style;
1590
+ ctx.restyled.push(fresh);
1591
+ }
1592
+ }
1593
+ if (!isCollection2(existing))
1064
1594
  carryComments(existing, fresh);
1065
1595
  return fresh;
1066
1596
  }
1067
- function reconcileMap(doc, map, value) {
1597
+ function carriedStyle(existing, value) {
1598
+ if (typeof existing.value !== "string")
1599
+ return void 0;
1600
+ switch (existing.type) {
1601
+ case Scalar.QUOTE_DOUBLE:
1602
+ case Scalar.QUOTE_SINGLE:
1603
+ return existing.type;
1604
+ case Scalar.BLOCK_FOLDED:
1605
+ case Scalar.BLOCK_LITERAL:
1606
+ return /\S/.test(value) && !/^\s/.test(value) ? existing.type : void 0;
1607
+ default:
1608
+ return void 0;
1609
+ }
1610
+ }
1611
+ function reconcileMap(ctx, map, value) {
1068
1612
  const wanted = new Set(Object.keys(value).filter((key) => value[key] !== void 0));
1069
1613
  const seen = /* @__PURE__ */ new Set();
1070
1614
  const retained = [];
@@ -1073,17 +1617,17 @@ function reconcileMap(doc, map, value) {
1073
1617
  if (key === void 0 || !wanted.has(key) || seen.has(key))
1074
1618
  continue;
1075
1619
  seen.add(key);
1076
- pair.value = reconcileNode(doc, pair.value, value[key]);
1620
+ pair.value = reconcileNode(ctx, pair.value, value[key]);
1077
1621
  retained.push(pair);
1078
1622
  }
1079
1623
  map.items = retained;
1080
1624
  for (const key of Object.keys(value)) {
1081
1625
  if (seen.has(key) || !wanted.has(key))
1082
1626
  continue;
1083
- map.set(doc.createNode(key), doc.createNode(value[key]));
1627
+ map.set(ctx.doc.createNode(key), ctx.doc.createNode(value[key]));
1084
1628
  }
1085
1629
  }
1086
- function reconcileSeq(doc, seq, value) {
1630
+ function reconcileSeq(ctx, seq, value) {
1087
1631
  const oldItems = seq.items;
1088
1632
  const byIdentity = /* @__PURE__ */ new Map();
1089
1633
  oldItems.forEach((node, index) => {
@@ -1117,20 +1661,45 @@ function reconcileSeq(doc, seq, value) {
1117
1661
  return oldItems[matched];
1118
1662
  const candidate = index < oldItems.length && !consumed.has(index) ? oldItems[index] : void 0;
1119
1663
  const sameItem = candidate !== void 0 && looksLikeSameItem(candidate, item) ? candidate : void 0;
1120
- return reconcileNode(doc, sameItem, item);
1664
+ return reconcileNode(ctx, sameItem, item);
1121
1665
  });
1122
1666
  }
1123
- function applyDataToDocument(doc, data) {
1124
- doc.contents = reconcileNode(doc, doc.contents, data);
1667
+ function reconcileYamlSource(raw, data, rootAtColumnZero = false) {
1668
+ const doc = parseDocument2(raw);
1669
+ if (doc.errors.length > 0)
1670
+ return void 0;
1671
+ const snapshot = snapshotDocument(doc);
1672
+ const ctx = { doc, replaced: /* @__PURE__ */ new WeakMap(), restyled: [] };
1673
+ doc.contents = reconcileNode(ctx, doc.contents, data);
1674
+ let reconciled = withSourceLineEndings(doc.toString(), raw);
1675
+ if (!readsBackAs(reconciled, doc)) {
1676
+ for (const scalar of ctx.restyled)
1677
+ scalar.type = void 0;
1678
+ reconciled = withSourceLineEndings(doc.toString(), raw);
1679
+ if (!readsBackAs(reconciled, doc))
1680
+ return void 0;
1681
+ }
1682
+ try {
1683
+ const spliced = spliceSource(raw, doc, snapshot, ctx.replaced, reconciled);
1684
+ if (spliced !== void 0 && !(rootAtColumnZero && /^\s*[ \t]\S/.test(spliced))) {
1685
+ return spliced;
1686
+ }
1687
+ log.debug("content-serialize", "source splice not used; writing as yaml prints it");
1688
+ } catch (err) {
1689
+ log.debug("content-serialize", "source splice failed; writing as yaml prints it", {
1690
+ error: getErrorMessage(err)
1691
+ });
1692
+ }
1693
+ return reconciled;
1694
+ }
1695
+ function readsBackAs(text, doc) {
1696
+ const reparsed = parseDocument2(text);
1697
+ return reparsed.errors.length === 0 && isDeepStrictEqual2(reparsed.toJS(), doc.toJS());
1125
1698
  }
1126
1699
  function serializeYaml(data, existingRaw) {
1127
1700
  if (existingRaw === void 0)
1128
1701
  return yamlStringify(data);
1129
- const doc = parseDocument(existingRaw);
1130
- if (doc.errors.length > 0)
1131
- return yamlStringify(data);
1132
- applyDataToDocument(doc, data);
1133
- return doc.toString();
1702
+ return reconcileYamlSource(existingRaw, data) ?? yamlStringify(data);
1134
1703
  }
1135
1704
  function extractRawFrontmatter(raw) {
1136
1705
  let parsed;
@@ -1150,11 +1719,9 @@ function serializeFrontmatter(body, data, existingRaw) {
1150
1719
  const existingFrontmatter = extractRawFrontmatter(existingRaw);
1151
1720
  if (existingFrontmatter === void 0)
1152
1721
  return matter.stringify(body, data);
1153
- const doc = parseDocument(existingFrontmatter);
1154
- if (doc.errors.length > 0)
1722
+ const reconciled = reconcileYamlSource(existingFrontmatter, data, true);
1723
+ if (reconciled === void 0)
1155
1724
  return matter.stringify(body, data);
1156
- applyDataToDocument(doc, data);
1157
- const reconciled = doc.toString();
1158
1725
  return matter.stringify(body, data, {
1159
1726
  engines: {
1160
1727
  yaml: {
@@ -1193,12 +1760,15 @@ import fs2 from "node:fs/promises";
1193
1760
  import path2 from "node:path";
1194
1761
  import lockfile from "proper-lockfile";
1195
1762
  init_logger();
1196
- function provisioningLockOptions(lockPath, retries, onCompromised) {
1763
+ var PROVISIONING_LOCK_STALE_MS = 9e4;
1764
+ function provisioningLockOptions(lockPath, retries, onCompromised, staleMs) {
1197
1765
  return {
1198
1766
  lockfilePath: lockPath,
1199
1767
  realpath: false,
1200
1768
  retries,
1201
- stale: 3e4,
1769
+ stale: staleMs,
1770
+ // Fixed, not stale/2: every holder refreshes at 15s whatever threshold it judges others by.
1771
+ update: 15e3,
1202
1772
  // proper-lockfile invokes this from inside its refresh timer, so ANY throw escaping here is
1203
1773
  // an uncaught exception that kills the process. Call sites are told not to throw (see
1204
1774
  // OnLockCompromised); this makes it structural rather than a convention, and also covers the
@@ -1234,37 +1804,68 @@ function releaseIgnoringAlreadyReleased(release, lockPath) {
1234
1804
  }
1235
1805
  };
1236
1806
  }
1807
+ function branchProvisioningLockName(dirName) {
1808
+ return `.${dirName}.init.lock`;
1809
+ }
1237
1810
  async function acquireProvisioningLock(lockTargetDir2, lockName, onCompromised) {
1238
1811
  await fs2.mkdir(lockTargetDir2, { recursive: true });
1239
1812
  const lockPath = path2.join(lockTargetDir2, lockName);
1240
- const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised));
1813
+ const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised, PROVISIONING_LOCK_STALE_MS));
1241
1814
  return releaseIgnoringAlreadyReleased(release, lockPath);
1242
1815
  }
1243
- async function tryAcquireProvisioningLock(lockTargetDir2, lockName, onCompromised) {
1244
- await fs2.mkdir(lockTargetDir2, { recursive: true });
1816
+ async function tryAcquireProvisioningLock(lockTargetDir2, lockName, onCompromised, staleMs = PROVISIONING_LOCK_STALE_MS, createParents = true) {
1817
+ if (createParents) {
1818
+ await fs2.mkdir(lockTargetDir2, { recursive: true });
1819
+ } else {
1820
+ await fs2.mkdir(lockTargetDir2).catch((err) => {
1821
+ if (!isNodeError(err) || err.code !== "EEXIST")
1822
+ throw err;
1823
+ });
1824
+ }
1245
1825
  const lockPath = path2.join(lockTargetDir2, lockName);
1246
- const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised));
1826
+ const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised, staleMs));
1247
1827
  return releaseIgnoringAlreadyReleased(release, lockPath);
1248
1828
  }
1829
+ var sleep = (ms) => new Promise((resolve2) => {
1830
+ setTimeout(resolve2, ms);
1831
+ });
1832
+ async function acquireProvisioningLockWithin(lockTargetDir2, lockName, waitMs, onCompromised) {
1833
+ const startedAt = Date.now();
1834
+ for (let attempt = 0; ; attempt++) {
1835
+ try {
1836
+ return await tryAcquireProvisioningLock(lockTargetDir2, lockName, onCompromised);
1837
+ } catch (err) {
1838
+ if (!isNodeError(err) || err.code !== "ELOCKED")
1839
+ throw err;
1840
+ const remaining = waitMs - (Date.now() - startedAt);
1841
+ if (remaining <= 0)
1842
+ throw err;
1843
+ const base = Math.min(50 * 2 ** attempt, 500);
1844
+ await sleep(Math.min(base * (0.5 + Math.random()), remaining));
1845
+ }
1846
+ }
1847
+ }
1249
1848
 
1250
1849
  // dist/utils/content-write-lock.js
1251
1850
  var META_DIR = ".canopy-meta";
1252
1851
  var CONTENT_WRITE_LOCK_NAME = "content-write.lock";
1253
1852
  var DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2e3;
1254
1853
  var ContentWriteLockBusyError = class extends Error {
1255
- constructor(message = "This branch is busy (syncing with the base branch, or another save is in flight); the change was not saved. Try again in a moment.") {
1854
+ constructor(message = "This branch is busy (syncing with the base branch, or another save is in flight); the change was not saved. Try again in a moment.", outcome = "not-run") {
1256
1855
  super(message);
1856
+ this.outcome = outcome;
1257
1857
  this.name = "ContentWriteLockBusyError";
1258
1858
  }
1259
1859
  };
1260
1860
  function lockTargetDir(branchRoot) {
1261
1861
  return path3.join(path3.resolve(branchRoot), META_DIR);
1262
1862
  }
1263
- var sleep = (ms) => new Promise((resolve2) => {
1863
+ var sleep2 = (ms) => new Promise((resolve2) => {
1264
1864
  setTimeout(resolve2, ms);
1265
1865
  });
1866
+ var CONTENT_WRITE_LOCK_STALE_MS = 3e4;
1266
1867
  function tryAcquireContentWriteLock(branchRoot, onCompromised) {
1267
- return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised);
1868
+ return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised, CONTENT_WRITE_LOCK_STALE_MS, false);
1268
1869
  }
1269
1870
  async function acquireContentWriteLock(branchRoot, waitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, onCompromised) {
1270
1871
  const startedAt = Date.now();
@@ -1278,7 +1879,7 @@ async function acquireContentWriteLock(branchRoot, waitMs = DEFAULT_CONTENT_WRIT
1278
1879
  if (remaining <= 0)
1279
1880
  throw new ContentWriteLockBusyError();
1280
1881
  const base = Math.min(40 * 2 ** attempt, 250);
1281
- await sleep(Math.min(base * (0.5 + Math.random()), remaining));
1882
+ await sleep2(Math.min(base * (0.5 + Math.random()), remaining));
1282
1883
  }
1283
1884
  }
1284
1885
  }
@@ -1295,7 +1896,7 @@ async function withContentWriteLock(branchRoot, fn, waitMs = DEFAULT_CONTENT_WRI
1295
1896
  await release();
1296
1897
  }
1297
1898
  if (compromised) {
1298
- throw new ContentWriteLockBusyError("This branch was being synced while your change was written, so the change may or may not have been recorded. Reload the entry to see the current state before saving again.");
1899
+ throw new ContentWriteLockBusyError("This branch was being synced while your change was written, so the change may or may not have been recorded. Reload the entry to see the current state before saving again.", "unknown");
1299
1900
  }
1300
1901
  return result;
1301
1902
  }
@@ -1345,8 +1946,21 @@ function buildResolvedReference(data, meta, body) {
1345
1946
  resolved.slug = meta.slug;
1346
1947
  resolved.collection = meta.collection;
1347
1948
  resolved.urlPath = meta.urlPath;
1949
+ delete resolved[RESTRICTED_REFERENCE_MARKER];
1348
1950
  return resolved;
1349
1951
  }
1952
+ var RESTRICTED_REFERENCE_MARKER = "unavailable";
1953
+ function buildRestrictedReference(meta, title) {
1954
+ return {
1955
+ id: meta.id,
1956
+ slug: meta.slug,
1957
+ collection: meta.collection,
1958
+ urlPath: meta.urlPath,
1959
+ title,
1960
+ unavailable: true,
1961
+ reason: "restricted"
1962
+ };
1963
+ }
1350
1964
  var SEO_GROUP_FIELDS = [
1351
1965
  {
1352
1966
  name: DEFAULT_SEO_FIELD_NAMES.title,
@@ -1399,15 +2013,59 @@ var SEO_GROUP_FIELDS = [
1399
2013
  }
1400
2014
  ];
1401
2015
 
1402
- // dist/utils/entry-url.js
1403
- init_normalize();
1404
- function isIndexSlug(slug) {
1405
- return slug?.toLowerCase() === "index";
2016
+ // dist/utils/title-field.js
2017
+ function isGroupField(field) {
2018
+ return field.type === "group";
1406
2019
  }
1407
- function computeEntryUrl(collection, slug, contentRoot) {
1408
- const root = trimSlashes(contentRoot);
1409
- let stripped = collection;
1410
- if (root && collection.startsWith(`${root}/`)) {
2020
+ function extractTitleFromSchema(fields, data) {
2021
+ return findTitleValue(fields, data);
2022
+ }
2023
+ function findTitleValue(fields, data) {
2024
+ for (const field of fields) {
2025
+ if (isGroupField(field)) {
2026
+ const result = findTitleValue(field.fields, data);
2027
+ if (result !== void 0)
2028
+ return result;
2029
+ } else if ("isTitle" in field && field.isTitle) {
2030
+ const value = data[field.name];
2031
+ return typeof value === "string" ? value : void 0;
2032
+ } else if (field.type === "object" && "fields" in field && field.fields && !field.list) {
2033
+ const nested = data[field.name];
2034
+ if (nested != null && typeof nested === "object" && !Array.isArray(nested)) {
2035
+ const result = findTitleValue(field.fields, nested);
2036
+ if (result !== void 0)
2037
+ return result;
2038
+ }
2039
+ }
2040
+ }
2041
+ return void 0;
2042
+ }
2043
+ function humanizeSlug(slug) {
2044
+ return slug.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim();
2045
+ }
2046
+ function resolveEntryTitle(data, options) {
2047
+ if (options?.schema) {
2048
+ const schemaTitle = extractTitleFromSchema(options.schema, data);
2049
+ if (schemaTitle)
2050
+ return schemaTitle;
2051
+ }
2052
+ const title = data.title ?? data.name;
2053
+ if (typeof title === "string")
2054
+ return title;
2055
+ if (options?.entryTypeLabel)
2056
+ return options.entryTypeLabel;
2057
+ return options?.slug ? humanizeSlug(options.slug) : "Untitled";
2058
+ }
2059
+
2060
+ // dist/utils/entry-url.js
2061
+ init_normalize();
2062
+ function isIndexSlug(slug) {
2063
+ return slug?.toLowerCase() === "index";
2064
+ }
2065
+ function computeEntryUrl(collection, slug, contentRoot) {
2066
+ const root = trimSlashes(contentRoot);
2067
+ let stripped = collection;
2068
+ if (root && collection.startsWith(`${root}/`)) {
1411
2069
  stripped = collection.slice(root.length + 1);
1412
2070
  } else if (collection === root) {
1413
2071
  stripped = "";
@@ -1416,8 +2074,8 @@ function computeEntryUrl(collection, slug, contentRoot) {
1416
2074
  if (slug && !isIndexSlug(slug)) {
1417
2075
  segments.push(slug);
1418
2076
  }
1419
- const path22 = segments.length > 0 ? `/${segments.join("/")}` : "/";
1420
- return path22.toLowerCase();
2077
+ const path24 = segments.length > 0 ? `/${segments.join("/")}` : "/";
2078
+ return path24.toLowerCase();
1421
2079
  }
1422
2080
 
1423
2081
  // dist/url-collision.js
@@ -1790,13 +2448,13 @@ function extractIdFromFilename(filename) {
1790
2448
  return null;
1791
2449
  }
1792
2450
  async function resolveCollectionPath(root, logicalPath) {
1793
- const fs19 = await import("node:fs/promises");
1794
- const path22 = await import("node:path");
2451
+ const fs21 = await import("node:fs/promises");
2452
+ const path24 = await import("node:path");
1795
2453
  const segments = logicalPath.split("/").filter(Boolean);
1796
2454
  let currentPath = root;
1797
2455
  for (const segment of segments) {
1798
2456
  try {
1799
- const entries = await fs19.readdir(currentPath, { withFileTypes: true });
2457
+ const entries = await fs21.readdir(currentPath, { withFileTypes: true });
1800
2458
  const matchingDir = entries.find((entry) => {
1801
2459
  if (!entry.isDirectory())
1802
2460
  return false;
@@ -1804,7 +2462,7 @@ async function resolveCollectionPath(root, logicalPath) {
1804
2462
  return logicalName === segment.toLowerCase();
1805
2463
  });
1806
2464
  if (matchingDir) {
1807
- currentPath = path22.join(currentPath, matchingDir.name);
2465
+ currentPath = path24.join(currentPath, matchingDir.name);
1808
2466
  } else {
1809
2467
  return null;
1810
2468
  }
@@ -1990,98 +2648,7 @@ function invalidateContentIndexesForRoot(root) {
1990
2648
  import fs5 from "node:fs/promises";
1991
2649
  import path7 from "node:path";
1992
2650
  import { randomUUID } from "node:crypto";
1993
-
1994
- // dist/utils/debug.js
1995
- var LOG_LEVELS = {
1996
- DEBUG: 0,
1997
- INFO: 1,
1998
- WARN: 2,
1999
- ERROR: 3
2000
- };
2001
- var DebugLogger = class {
2002
- constructor(options = {}) {
2003
- this.timers = /* @__PURE__ */ new Map();
2004
- this.options = options;
2005
- }
2006
- shouldLog(level) {
2007
- const enabled = this.options.enabled ?? process.env.CANOPYCMS_DEBUG === "true";
2008
- if (!enabled)
2009
- return false;
2010
- const minLevel = this.options.minLevel ?? "DEBUG";
2011
- return LOG_LEVELS[level] >= LOG_LEVELS[minLevel];
2012
- }
2013
- /**
2014
- * The ISO-8601 timestamp leads the line BARE, never bracketed. `CANOPYCMS_DEBUG=true` can
2015
- * be set on the EC2 worker, whose stdout appends to /var/log/canopy-worker/worker.log, and
2016
- * the CloudWatch agent's `multi_line_start_pattern` matches a timestamp at the START of a
2017
- * line (see worker/log.ts's INVARIANT and cms-service.ts's agent config): any leading
2018
- * character folds the line into the previous event instead of starting its own.
2019
- */
2020
- formatMessage(level, category, message) {
2021
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2022
- const prefix = this.options.prefix ?? "CanopyCMS";
2023
- return `${timestamp} [${prefix}:${category}] [${level}] ${message}`;
2024
- }
2025
- debug(category, message, data) {
2026
- if (this.shouldLog("DEBUG")) {
2027
- console.log(this.formatMessage("DEBUG", category, message), data ?? "");
2028
- }
2029
- }
2030
- info(category, message, data) {
2031
- if (this.shouldLog("INFO")) {
2032
- console.log(this.formatMessage("INFO", category, message), data ?? "");
2033
- }
2034
- }
2035
- warn(category, message, data) {
2036
- if (this.shouldLog("WARN")) {
2037
- console.warn(this.formatMessage("WARN", category, message), data ?? "");
2038
- }
2039
- }
2040
- error(category, message, data) {
2041
- const msg = this.formatMessage("ERROR", category, message);
2042
- if (this.shouldLog("ERROR")) {
2043
- console.error(msg, data ?? "");
2044
- }
2045
- const throwOnError = this.options.throwOnError ?? false;
2046
- if (throwOnError) {
2047
- const errorMsg = data ? `${message}: ${JSON.stringify(data)}` : message;
2048
- throw new Error(errorMsg);
2049
- }
2050
- }
2051
- time(label) {
2052
- this.timers.set(label, Date.now());
2053
- }
2054
- timeEnd(category, label) {
2055
- const start = this.timers.get(label);
2056
- if (start === void 0) {
2057
- this.warn(category, `Timer '${label}' does not exist`);
2058
- return;
2059
- }
2060
- const duration = Date.now() - start;
2061
- this.timers.delete(label);
2062
- this.debug(category, `${label} completed`, { durationMs: duration });
2063
- return duration;
2064
- }
2065
- async timed(category, label, fn) {
2066
- this.time(label);
2067
- try {
2068
- return await fn();
2069
- } finally {
2070
- this.timeEnd(category, label);
2071
- }
2072
- }
2073
- };
2074
- function createDebugLogger(options) {
2075
- return new DebugLogger(options);
2076
- }
2077
- var testLogger = createDebugLogger({
2078
- enabled: process.env.E2E_DEBUG === "true",
2079
- prefix: "E2E",
2080
- throwOnError: false
2081
- });
2082
-
2083
- // dist/resource-generation.js
2084
- var log = createDebugLogger({ prefix: "ResourceGeneration" });
2651
+ var log2 = createDebugLogger({ prefix: "ResourceGeneration" });
2085
2652
  var META_DIR2 = ".canopy-meta";
2086
2653
  function resourceGenerationPath(root, resource) {
2087
2654
  return path7.resolve(root, META_DIR2, `${resource}.generation`);
@@ -2092,7 +2659,7 @@ async function bumpResourceGeneration(root, resource, options) {
2092
2659
  await atomicWriteFile(resourceGenerationPath(root, resource), token);
2093
2660
  return token;
2094
2661
  } catch (err) {
2095
- log.warn("generation", `Failed to bump ${resource} generation for ${root}`, {
2662
+ log2.warn("generation", `Failed to bump ${resource} generation for ${root}`, {
2096
2663
  error: getErrorMessage(err)
2097
2664
  });
2098
2665
  if (options?.mustSucceed)
@@ -2107,7 +2674,7 @@ async function readResourceGeneration(root, resource) {
2107
2674
  } catch (err) {
2108
2675
  if (isNodeError(err) && err.code === "ENOENT")
2109
2676
  return { ok: true, token: null };
2110
- log.warn("generation", `Failed to read ${resource} generation for ${root}`, {
2677
+ log2.warn("generation", `Failed to read ${resource} generation for ${root}`, {
2111
2678
  error: getErrorMessage(err)
2112
2679
  });
2113
2680
  return { ok: false };
@@ -2329,10 +2896,10 @@ var closestName = (name, known) => {
2329
2896
  const threshold = Math.max(2, Math.floor(name.length / 3));
2330
2897
  return best !== void 0 && bestDistance <= threshold ? best : void 0;
2331
2898
  };
2332
- var forEachEntryType = (schema, visit) => {
2899
+ var forEachEntryType = (schema, visit2) => {
2333
2900
  const walk = (node, nodePath) => {
2334
2901
  for (const entryType of node.entries ?? []) {
2335
- visit(entryType, nodePath);
2902
+ visit2(entryType, nodePath);
2336
2903
  }
2337
2904
  for (const child of node.collections ?? []) {
2338
2905
  walk(child, child.path ?? (nodePath ? `${nodePath}/${child.name}` : child.name));
@@ -2393,9 +2960,80 @@ var STATIC_DEPLOY_USER = Object.freeze({
2393
2960
  name: "Static Deploy"
2394
2961
  });
2395
2962
 
2963
+ // dist/utils/request-timing.js
2964
+ import { AsyncLocalStorage } from "node:async_hooks";
2965
+ var storage = new AsyncLocalStorage();
2966
+ var log3 = createDebugLogger({ prefix: "CanopyCMS" });
2967
+ async function timeRequestPhase(phase, fn) {
2968
+ const parent = storage.getStore();
2969
+ if (!parent)
2970
+ return fn();
2971
+ const key = parent.path ? `${parent.path}>${phase}` : phase;
2972
+ let total = parent.phases.get(key);
2973
+ if (!total) {
2974
+ total = { count: 0, ms: 0 };
2975
+ parent.phases.set(key, total);
2976
+ }
2977
+ const start = performance.now();
2978
+ try {
2979
+ return await storage.run({ ...parent, path: key }, fn);
2980
+ } finally {
2981
+ total.count += 1;
2982
+ total.ms += performance.now() - start;
2983
+ }
2984
+ }
2985
+
2986
+ // dist/utils/git.js
2987
+ import { simpleGit } from "simple-git";
2988
+ var NETWORK_SCHEME_PATTERN = /^(https?|ssh|git):\/\//i;
2989
+ var TRANSPORT_HELPER_PATTERN = /^[a-z][a-z0-9+.-]*::/i;
2990
+ var LEADING_OPTION_PATTERN = /^-/;
2991
+ var WINDOWS_DRIVE_PATTERN = /^[a-zA-Z]:[\\/]/;
2992
+ var COLON_BEFORE_SLASH_PATTERN = /^[^/\\\s]+:/;
2993
+ function isNetworkRemoteUrl(url) {
2994
+ if (/^file:\/\//i.test(url))
2995
+ return false;
2996
+ if (NETWORK_SCHEME_PATTERN.test(url))
2997
+ return true;
2998
+ if (TRANSPORT_HELPER_PATTERN.test(url))
2999
+ return true;
3000
+ if (LEADING_OPTION_PATTERN.test(url))
3001
+ return true;
3002
+ if (WINDOWS_DRIVE_PATTERN.test(url))
3003
+ return false;
3004
+ return COLON_BEFORE_SLASH_PATTERN.test(url);
3005
+ }
3006
+ var MISSING_REMOTE_REF_REASONS = ["couldn't find remote ref", "Couldn't find remote ref"];
3007
+ function isMissingRemoteRefFailure(message) {
3008
+ return MISSING_REMOTE_REF_REASONS.some((reason) => message.includes(reason));
3009
+ }
3010
+ var CANOPY_META_DIR = ".canopy-meta";
3011
+ async function stageAllExceptCanopyState(git) {
3012
+ await git.raw(["add", "-A", "--sparse"]);
3013
+ await git.raw(["reset", "-q", "--", CANOPY_META_DIR]);
3014
+ }
3015
+ async function detectHeadBranch(repoRoot, fallback = "main") {
3016
+ try {
3017
+ const git = simpleGit({ baseDir: repoRoot });
3018
+ const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
3019
+ return head && head !== "HEAD" ? head : fallback;
3020
+ } catch {
3021
+ return fallback;
3022
+ }
3023
+ }
3024
+ async function resolveBaseBranch(options) {
3025
+ if (options.defaultBaseBranch)
3026
+ return options.defaultBaseBranch;
3027
+ if (options.mode === "dev") {
3028
+ return detectHeadBranch(options.detectFrom ?? process.cwd());
3029
+ }
3030
+ return "main";
3031
+ }
3032
+
2396
3033
  // dist/branch-schema-cache.js
2397
3034
  var SCHEMA_CACHE_VERSION = 3;
2398
3035
  var MTIME_CHECK_DEBOUNCE_MS = 1e3;
3036
+ var SCHEMA_CACHE_FILE = "schema-cache.json";
2399
3037
  var SCHEMA_GENERATION_RESOURCE = "schema";
2400
3038
  async function isStaleByMtime(dir, cachedAt) {
2401
3039
  let entries;
@@ -2418,6 +3056,11 @@ async function isStaleByMtime(dir, cachedAt) {
2418
3056
  }
2419
3057
  return false;
2420
3058
  }
3059
+ async function schemaCacheDir(branchRoot) {
3060
+ const gitDir = path8.join(branchRoot, ".git");
3061
+ const isClone = await fs7.stat(gitDir).then((stat) => stat.isDirectory(), () => false);
3062
+ return isClone ? path8.join(gitDir, "canopycms") : path8.join(branchRoot, CANOPY_META_DIR);
3063
+ }
2421
3064
  var BranchSchemaCache = class {
2422
3065
  constructor(mode = "prod") {
2423
3066
  this.lastMtimeCheck = /* @__PURE__ */ new Map();
@@ -2426,7 +3069,7 @@ var BranchSchemaCache = class {
2426
3069
  /**
2427
3070
  * Whether to skip the on-disk cache for this branchRoot.
2428
3071
  *
2429
- * Never write `.canopy-meta/` at the project root, whichever entrypoint
3072
+ * Never write a schema cache at the project root, whichever entrypoint
2430
3073
  * produced the cwd branchRoot. branchRoot equals process.cwd() only in the
2431
3074
  * synthetic contexts static deployments and build phases use; a real branch
2432
3075
  * root is always nested under the workspace.
@@ -2436,7 +3079,7 @@ var BranchSchemaCache = class {
2436
3079
  }
2437
3080
  /** Get schema for a branch, from the cache when fresh, else resolved fresh. */
2438
3081
  async getSchema(branchRoot, entrySchemaRegistry, contentRootName = "content") {
2439
- return this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName);
3082
+ return timeRequestPhase("schema", () => this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName));
2440
3083
  }
2441
3084
  /**
2442
3085
  * Resolve the schema from disk. Protected rather than a direct resolveSchema
@@ -2450,8 +3093,7 @@ var BranchSchemaCache = class {
2450
3093
  const contentRoot = path8.join(branchRoot, contentRootName);
2451
3094
  const skipDiskCache = this.skipDiskCache(branchRoot);
2452
3095
  if (!skipDiskCache) {
2453
- const cacheDir = path8.join(branchRoot, ".canopy-meta");
2454
- const cachePath = path8.join(cacheDir, "schema-cache.json");
3096
+ const cachePath = path8.join(await schemaCacheDir(branchRoot), SCHEMA_CACHE_FILE);
2455
3097
  let cacheData = null;
2456
3098
  try {
2457
3099
  const cacheContent = await fs7.readFile(cachePath, "utf-8");
@@ -2493,7 +3135,7 @@ var BranchSchemaCache = class {
2493
3135
  const { skipDiskCache } = options;
2494
3136
  const contentRoot = path8.join(branchRoot, contentRootName);
2495
3137
  const read = skipDiskCache ? null : await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
2496
- const result = await this.resolveFresh(contentRoot, entrySchemaRegistry);
3138
+ const result = await timeRequestPhase("resolve", () => this.resolveFresh(contentRoot, entrySchemaRegistry));
2497
3139
  if (!isValidSchema(result.schema)) {
2498
3140
  throw new Error(`No schema found in ${contentRoot}. Create .collection.json files with references to field schemas defined in your entry schema registry.`);
2499
3141
  }
@@ -2504,9 +3146,9 @@ var BranchSchemaCache = class {
2504
3146
  }
2505
3147
  const flatSchema = flattenSchema(result.schema, contentRootName);
2506
3148
  if (!skipDiskCache) {
2507
- const cacheDir = path8.join(branchRoot, ".canopy-meta");
2508
- const cachePath = path8.join(cacheDir, "schema-cache.json");
2509
- await fs7.unlink(path8.join(cacheDir, "schema-cache.stale")).catch(() => {
3149
+ const cacheDir = await schemaCacheDir(branchRoot);
3150
+ const cachePath = path8.join(cacheDir, SCHEMA_CACHE_FILE);
3151
+ await fs7.unlink(path8.join(branchRoot, CANOPY_META_DIR, "schema-cache.stale")).catch(() => {
2510
3152
  });
2511
3153
  if (read && read.ok) {
2512
3154
  const newCache = {
@@ -2634,7 +3276,6 @@ function validateAndNormalizePath(root, target) {
2634
3276
 
2635
3277
  // dist/paths/branch.js
2636
3278
  init_operating_mode();
2637
- import fs9 from "node:fs/promises";
2638
3279
  import path10 from "node:path";
2639
3280
 
2640
3281
  // dist/paths/branch-name.js
@@ -2644,6 +3285,11 @@ function sanitizeBranchName(branchName) {
2644
3285
  const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
2645
3286
  return trimmedDots || "branch";
2646
3287
  }
3288
+ var RESERVED_SETTINGS_BRANCH_PREFIX = "canopycms-settings-";
3289
+ function isSettingsBranchName(branchName, settingsBranchName) {
3290
+ const sanitized = sanitizeBranchName(branchName);
3291
+ return sanitized.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX) || settingsBranchName !== void 0 && sanitized === sanitizeBranchName(settingsBranchName);
3292
+ }
2647
3293
 
2648
3294
  // dist/paths/branch.js
2649
3295
  var BranchPathError = class extends Error {
@@ -2655,6 +3301,9 @@ function resolveBranchPath(options) {
2655
3301
  if (options.branchName.includes("..")) {
2656
3302
  throw new BranchPathError("Branch name cannot contain traversal segments");
2657
3303
  }
3304
+ if (isSettingsBranchName(options.branchName)) {
3305
+ throw new BranchPathError("Settings branches are not content branches");
3306
+ }
2658
3307
  const safeBranch = sanitizeBranchName(options.branchName);
2659
3308
  const strategy = operatingStrategy(options.mode);
2660
3309
  const baseRoot = resolveContentBranchesRoot(options.mode, options.basePathOverride);
@@ -2670,11 +3319,6 @@ function resolveBranchPath(options) {
2670
3319
  }
2671
3320
  return { branchRoot, baseRoot: normalizedBase, branchName: safeBranch };
2672
3321
  }
2673
- async function ensureBranchRoot(options) {
2674
- const result = resolveBranchPath(options);
2675
- await fs9.mkdir(result.branchRoot, { recursive: true });
2676
- return result;
2677
- }
2678
3322
 
2679
3323
  // dist/content-store.js
2680
3324
  async function withLocks(keys, fn) {
@@ -2884,7 +3528,7 @@ var ContentStore = class {
2884
3528
  async findEntryPathsById(dir, id) {
2885
3529
  let entries;
2886
3530
  try {
2887
- entries = await fs10.readdir(dir, { withFileTypes: true });
3531
+ entries = await fs9.readdir(dir, { withFileTypes: true });
2888
3532
  } catch (err) {
2889
3533
  if (isNodeError(err) && err.code === "ENOENT")
2890
3534
  return [];
@@ -2903,11 +3547,11 @@ var ContentStore = class {
2903
3547
  getSchemaItems() {
2904
3548
  return this.schemaIndex.values();
2905
3549
  }
2906
- assertSchemaItem(path22) {
2907
- const normalized = normalizeFilesystemPath(path22);
3550
+ assertSchemaItem(path24) {
3551
+ const normalized = normalizeFilesystemPath(path24);
2908
3552
  const item = this.schemaIndex.get(normalized);
2909
3553
  if (!item) {
2910
- throw new ContentStoreError(`Unknown schema item: ${path22}`, "NO_SCHEMA_ITEM");
3554
+ throw new ContentStoreError(`Unknown schema item: ${path24}`, "NO_SCHEMA_ITEM");
2911
3555
  }
2912
3556
  return item;
2913
3557
  }
@@ -3082,7 +3726,7 @@ var ContentStore = class {
3082
3726
  let existingEntryType;
3083
3727
  let foundExisting = false;
3084
3728
  if (!id) {
3085
- const entries = await fs10.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
3729
+ const entries = await fs9.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
3086
3730
  const existingFile = entries.find((entry) => {
3087
3731
  if (entry.isDirectory())
3088
3732
  return false;
@@ -3116,6 +3760,7 @@ var ContentStore = class {
3116
3760
  return {
3117
3761
  absolutePath: resolved,
3118
3762
  relativePath: path11.relative(this.root, resolved),
3763
+ logicalPath: entryLogicalPath(schemaItem.logicalPath, safeSlug),
3119
3764
  id,
3120
3765
  entryTypeName: finalEntryTypeName,
3121
3766
  existed
@@ -3145,11 +3790,15 @@ var ContentStore = class {
3145
3790
  const schemaItem = this.assertSchemaItem(schemaPath);
3146
3791
  return await this.buildPaths(schemaItem, slug);
3147
3792
  }
3793
+ /**
3794
+ * Read one entry. References resolve unless `resolveReferences: false`; a target that
3795
+ * `referenceAccess` denies resolves to a `RestrictedReference` rather than its data.
3796
+ */
3148
3797
  async read(collectionPath, slug = "", options = {}) {
3149
3798
  const schemaItem = this.assertSchemaItem(collectionPath);
3150
3799
  const { absolutePath, relativePath, entryTypeName: resolvedEntryTypeName } = await this.buildPaths(schemaItem, slug);
3151
- const stat = await fs10.stat(absolutePath);
3152
- const raw = await fs10.readFile(absolutePath, "utf8");
3800
+ const stat = await fs9.stat(absolutePath);
3801
+ const raw = await fs9.readFile(absolutePath, "utf8");
3153
3802
  let doc;
3154
3803
  let format;
3155
3804
  let fields;
@@ -3206,7 +3855,7 @@ var ContentStore = class {
3206
3855
  };
3207
3856
  }
3208
3857
  if (options.resolveReferences !== false) {
3209
- doc.data = await this.resolveReferencesInData(doc.data, fields);
3858
+ doc.data = await this.resolveReferencesInData(doc.data, fields, void 0, options.referenceAccess);
3210
3859
  }
3211
3860
  doc.version = stat.mtimeMs;
3212
3861
  return doc;
@@ -3275,10 +3924,10 @@ var ContentStore = class {
3275
3924
  }
3276
3925
  await this.assertUrlPathAvailable(path11.dirname(absolutePath), chosenSlug);
3277
3926
  }
3278
- await fs10.mkdir(path11.dirname(absolutePath), { recursive: true });
3927
+ await fs9.mkdir(path11.dirname(absolutePath), { recursive: true });
3279
3928
  if (input.expectedVersion !== void 0) {
3280
3929
  try {
3281
- const existing = await fs10.stat(absolutePath);
3930
+ const existing = await fs9.stat(absolutePath);
3282
3931
  if (input.expectedVersion === null) {
3283
3932
  throw new ContentConflictError("An entry with this slug already exists");
3284
3933
  }
@@ -3335,13 +3984,13 @@ var ContentStore = class {
3335
3984
  }
3336
3985
  }
3337
3986
  if (staleOldAbsPath) {
3338
- await fs10.unlink(staleOldAbsPath).catch((err) => {
3987
+ await fs9.unlink(staleOldAbsPath).catch((err) => {
3339
3988
  if (!isNodeError(err) || err.code !== "ENOENT")
3340
3989
  throw err;
3341
3990
  });
3342
3991
  }
3343
3992
  await this.recordOwnMutation(liveIndex);
3344
- const afterStat = await fs10.stat(absolutePath);
3993
+ const afterStat = await fs9.stat(absolutePath);
3345
3994
  const base = {
3346
3995
  collection: schemaItem.logicalPath,
3347
3996
  collectionName: schemaItem.name,
@@ -3418,7 +4067,7 @@ var ContentStore = class {
3418
4067
  const schemaItem = this.assertSchemaItem(collectionPath);
3419
4068
  const { absolutePath } = await this.buildPaths(schemaItem, slug);
3420
4069
  try {
3421
- await fs10.access(absolutePath);
4070
+ await fs9.access(absolutePath);
3422
4071
  return true;
3423
4072
  } catch (err) {
3424
4073
  if (isNodeError(err) && err.code === "ENOENT")
@@ -3453,7 +4102,7 @@ var ContentStore = class {
3453
4102
  const collectionRoot = await resolveCollectionPath(this.root, collectionPath);
3454
4103
  if (!collectionRoot)
3455
4104
  return 0;
3456
- const entries = await fs10.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
4105
+ const entries = await fs9.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
3457
4106
  return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
3458
4107
  }
3459
4108
  /**
@@ -3471,7 +4120,7 @@ var ContentStore = class {
3471
4120
  const collection = this.assertCollection(collectionPath);
3472
4121
  const prePass = await this.buildPaths(collection, slug);
3473
4122
  if (!prePass.existed) {
3474
- await fs10.unlink(prePass.absolutePath);
4123
+ await fs9.unlink(prePass.absolutePath);
3475
4124
  return;
3476
4125
  }
3477
4126
  let lockKey = this.entryLockKey(collection, slug, prePass);
@@ -3487,7 +4136,7 @@ var ContentStore = class {
3487
4136
  return RETRY_KEY;
3488
4137
  }
3489
4138
  const { absolutePath, relativePath } = inLock;
3490
- await fs10.unlink(absolutePath);
4139
+ await fs9.unlink(absolutePath);
3491
4140
  const liveIndex = this._idIndex;
3492
4141
  const id = liveIndex.findByPath(relativePath);
3493
4142
  if (id) {
@@ -3559,7 +4208,7 @@ var ContentStore = class {
3559
4208
  const parentDir = path11.dirname(currentPath);
3560
4209
  const newPath = path11.join(parentDir, newFilename);
3561
4210
  try {
3562
- const entries = await fs10.readdir(parentDir, { withFileTypes: true });
4211
+ const entries = await fs9.readdir(parentDir, { withFileTypes: true });
3563
4212
  for (const entry of entries) {
3564
4213
  if (entry.isDirectory())
3565
4214
  continue;
@@ -3574,14 +4223,14 @@ var ContentStore = class {
3574
4223
  }
3575
4224
  await this.assertUrlPathAvailable(parentDir, safeNewSlug);
3576
4225
  try {
3577
- await fs10.link(currentPath, newPath);
4226
+ await fs9.link(currentPath, newPath);
3578
4227
  } catch (err) {
3579
4228
  if (isNodeError(err) && err.code === "EEXIST") {
3580
4229
  throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
3581
4230
  }
3582
4231
  throw err;
3583
4232
  }
3584
- await fs10.unlink(currentPath);
4233
+ await fs9.unlink(currentPath);
3585
4234
  const newRelativePath = path11.relative(this.root, newPath);
3586
4235
  const liveIndex = this._idIndex;
3587
4236
  const entryId = liveIndex.findByPath(currentRelPath);
@@ -3660,20 +4309,25 @@ var ContentStore = class {
3660
4309
  * a target referenced by many entries is read once; omit `cache` for exactly `read()`'s
3661
4310
  * unmemoized behavior.
3662
4311
  *
3663
- * **Path ACLs are not consulted for the targets.** Resolution goes through this store's own
3664
- * `read()`, below the per-entry permission check in content-reader.ts, so a resolved target
3665
- * may be an entry the caller could not `read()` directly -- pre-existing `read()` behavior,
3666
- * matched here on purpose so one rule covers both.
4312
+ * `access` applies to every target exactly as in `read()`; see {@link ReferenceTargetAccess}.
4313
+ */
4314
+ async resolveReferences(data, fields, cache, access) {
4315
+ return this.resolveReferencesInData(data, fields, cache, access);
4316
+ }
4317
+ /**
4318
+ * Resolve one reference id the way a reference field resolves it, for the editor's
4319
+ * live-preview endpoint (api/resolve-references.ts), which has ids but no field to walk. Null
4320
+ * when the id names no entry. No body is embedded, since there is no field to ask for one.
3667
4321
  */
3668
- async resolveReferences(data, fields, cache) {
3669
- return this.resolveReferencesInData(data, fields, cache);
4322
+ async resolveReferenceTarget(id, access) {
4323
+ return this.resolveSingleReference(id, await this.idIndex(), false, void 0, access);
3670
4324
  }
3671
- async resolveReferencesInData(data, fields, cache) {
4325
+ async resolveReferencesInData(data, fields, cache, access) {
3672
4326
  const resolved = { ...data };
3673
4327
  const idIndex = await this.idIndex();
3674
4328
  for (const field of fields) {
3675
4329
  if (field.type === "group") {
3676
- const groupResolved = await this.resolveReferencesInData(resolved, field.fields, cache);
4330
+ const groupResolved = await this.resolveReferencesInData(resolved, field.fields, cache, access);
3677
4331
  Object.assign(resolved, groupResolved);
3678
4332
  continue;
3679
4333
  }
@@ -3681,18 +4335,18 @@ var ContentStore = class {
3681
4335
  if (field.type === "reference") {
3682
4336
  const includeBody = field.includeBody === true;
3683
4337
  if (typeof value === "string" && value) {
3684
- resolved[field.name] = await this.resolveSingleReference(value, idIndex, includeBody, cache);
4338
+ resolved[field.name] = await this.resolveSingleReference(value, idIndex, includeBody, cache, access);
3685
4339
  } else if (field.list && Array.isArray(value)) {
3686
- resolved[field.name] = await Promise.all(value.map((id) => typeof id === "string" ? this.resolveSingleReference(id, idIndex, includeBody, cache) : null));
4340
+ resolved[field.name] = await Promise.all(value.map((id) => typeof id === "string" ? this.resolveSingleReference(id, idIndex, includeBody, cache, access) : null));
3687
4341
  }
3688
4342
  } else if (field.type === "object" && value) {
3689
4343
  const objectField = field;
3690
4344
  if (!objectField.fields)
3691
4345
  continue;
3692
4346
  if (objectField.list && Array.isArray(value)) {
3693
- resolved[field.name] = await Promise.all(value.map((item) => typeof item === "object" && item !== null ? this.resolveReferencesInData(item, objectField.fields, cache) : item));
4347
+ resolved[field.name] = await Promise.all(value.map((item) => typeof item === "object" && item !== null ? this.resolveReferencesInData(item, objectField.fields, cache, access) : item));
3694
4348
  } else if (typeof value === "object") {
3695
- resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields, cache);
4349
+ resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields, cache, access);
3696
4350
  }
3697
4351
  } else if (field.type === "block" && Array.isArray(value)) {
3698
4352
  const blockField = field;
@@ -3705,7 +4359,7 @@ var ContentStore = class {
3705
4359
  return block;
3706
4360
  return {
3707
4361
  ...b,
3708
- value: await this.resolveReferencesInData(b.value, template.fields, cache)
4362
+ value: await this.resolveReferencesInData(b.value, template.fields, cache, access)
3709
4363
  };
3710
4364
  }));
3711
4365
  }
@@ -3736,13 +4390,13 @@ var ContentStore = class {
3736
4390
  * `includeBody` is opt-in per field: an embedded body sits inside the memoized object and is
3737
4391
  * cloned once per referencing entry.
3738
4392
  */
3739
- resolveSingleReference(id, idIndex, includeBody, cache) {
4393
+ resolveSingleReference(id, idIndex, includeBody, cache, access) {
3740
4394
  if (!cache)
3741
- return this.resolveSingleReferenceUncached(id, idIndex, includeBody);
4395
+ return this.resolveSingleReferenceUncached(id, idIndex, includeBody, access);
3742
4396
  const key = includeBody ? `${id}:body` : id;
3743
4397
  let pending = cache.get(key);
3744
4398
  if (!pending) {
3745
- pending = this.resolveSingleReferenceUncached(id, idIndex, includeBody);
4399
+ pending = this.resolveSingleReferenceUncached(id, idIndex, includeBody, access);
3746
4400
  cache.set(key, pending);
3747
4401
  }
3748
4402
  return pending.then((resolved) => resolved === null ? null : structuredClone(resolved));
@@ -3760,15 +4414,27 @@ var ContentStore = class {
3760
4414
  * A {@link ReferenceResolveCache} sits ABOVE this, never inside it, so every distinct id
3761
4415
  * still runs the full refresh-and-retry; a cache only makes repeats of the SAME id share it.
3762
4416
  */
3763
- async resolveSingleReferenceUncached(id, idIndex, includeBody) {
3764
- const first = await this.resolveSingleReferenceOnce(id, idIndex, includeBody);
4417
+ async resolveSingleReferenceUncached(id, idIndex, includeBody, access) {
4418
+ const first = await this.resolveSingleReferenceOnce(id, idIndex, includeBody, access);
3765
4419
  if (first !== STALE_LOOKUP)
3766
4420
  return first;
3767
4421
  await this.refreshIndexForSuspiciousLookup();
3768
- const second = await this.resolveSingleReferenceOnce(id, await this.idIndex(), includeBody);
4422
+ const second = await this.resolveSingleReferenceOnce(id, await this.idIndex(), includeBody, access);
3769
4423
  return second === STALE_LOOKUP ? null : second;
3770
4424
  }
3771
- async resolveSingleReferenceOnce(id, idIndex, includeBody) {
4425
+ /**
4426
+ * The entry type an entry file was written as, by its filename's type token, falling back to
4427
+ * the collection's default the way `read()` does for a legacy untyped file.
4428
+ */
4429
+ entryTypeOf(collectionPath, relativePath) {
4430
+ const schemaItem = this.assertSchemaItem(collectionPath);
4431
+ if (schemaItem.type === "entry-type")
4432
+ return schemaItem;
4433
+ const entries = schemaItem.entries;
4434
+ const typeName = extractEntryTypeFromFilename(path11.basename(relativePath));
4435
+ return entries?.find((e) => e.name === typeName) ?? getDefaultEntryType(schemaItem.entries);
4436
+ }
4437
+ async resolveSingleReferenceOnce(id, idIndex, includeBody, access) {
3772
4438
  try {
3773
4439
  const location = idIndex.findById(id);
3774
4440
  if (!location)
@@ -3779,13 +4445,22 @@ var ContentStore = class {
3779
4445
  const doc = await this.read(location.collection, location.slug, {
3780
4446
  resolveReferences: false
3781
4447
  });
3782
- const bodyForEmbed = includeBody && "body" in doc ? { fieldName: doc.bodyFieldName, value: doc.body } : void 0;
3783
- return buildResolvedReference(doc.data, {
4448
+ const meta = {
3784
4449
  id,
3785
4450
  slug: location.slug,
3786
4451
  collection: location.collection,
3787
4452
  urlPath: computeEntryUrl(location.collection, location.slug, this.contentRootName)
3788
- }, bodyForEmbed);
4453
+ };
4454
+ if (access && !access(entryLogicalPath(location.collection, location.slug))) {
4455
+ const entryType = this.entryTypeOf(location.collection, location.relativePath);
4456
+ return buildRestrictedReference(meta, resolveEntryTitle(doc.data, {
4457
+ schema: entryType?.schema,
4458
+ entryTypeLabel: entryType?.label,
4459
+ slug: location.slug
4460
+ }));
4461
+ }
4462
+ const bodyForEmbed = includeBody && "body" in doc ? { fieldName: doc.bodyFieldName, value: doc.body } : void 0;
4463
+ return buildResolvedReference(doc.data, meta, bodyForEmbed);
3789
4464
  } catch (error) {
3790
4465
  if (isNodeError(error) && error.code === "ENOENT")
3791
4466
  return STALE_LOOKUP;
@@ -3797,7 +4472,7 @@ var ContentStore = class {
3797
4472
 
3798
4473
  // dist/ai/generate.js
3799
4474
  import path12 from "node:path";
3800
- import fs11 from "node:fs/promises";
4475
+ import fs10 from "node:fs/promises";
3801
4476
  import { minimatch } from "minimatch";
3802
4477
 
3803
4478
  // dist/ai/strip-mdx.js
@@ -4353,7 +5028,7 @@ function yamlValue(value) {
4353
5028
  }
4354
5029
 
4355
5030
  // dist/entry-link-resolver.js
4356
- var log2 = createDebugLogger({ prefix: "EntryLinks" });
5031
+ var log4 = createDebugLogger({ prefix: "EntryLinks" });
4357
5032
  var BASE58_CHAR = "[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]";
4358
5033
  var ENTRY_LINK_PATTERN = new RegExp(`entry:(${BASE58_CHAR}{12})(#[^\\s)>"']*)?`, "g");
4359
5034
  function resolveEntryUrl(location, contentRoot) {
@@ -4367,7 +5042,7 @@ function resolveEntryLinksInText(text, idIndex, contentRoot, customResolver) {
4367
5042
  return part.text.replace(ENTRY_LINK_PATTERN, (_match, id, anchor) => {
4368
5043
  const location = idIndex.findById(id);
4369
5044
  if (!location || location.type !== "entry" || !location.collection || !location.slug) {
4370
- log2.warn("resolve", `Entry link target not found: entry:${id}`);
5045
+ log4.warn("resolve", `Entry link target not found: entry:${id}`);
4371
5046
  return anchor ?? "#";
4372
5047
  }
4373
5048
  let url;
@@ -4669,10 +5344,10 @@ function makeReadSibling(dir) {
4669
5344
  if (path12.dirname(abs) !== resolvedDir)
4670
5345
  return null;
4671
5346
  try {
4672
- const stats = await fs11.lstat(abs);
5347
+ const stats = await fs10.lstat(abs);
4673
5348
  if (!stats.isFile())
4674
5349
  return null;
4675
- return await fs11.readFile(abs, "utf8");
5350
+ return await fs10.readFile(abs, "utf8");
4676
5351
  } catch (err) {
4677
5352
  if (isNodeError(err) && (err.code === "ENOENT" || err.code === "ENOTDIR"))
4678
5353
  return null;
@@ -4723,18 +5398,19 @@ function matchesBundleFilter(entry, filter, contentRoot) {
4723
5398
  }
4724
5399
 
4725
5400
  // dist/branch-workspace.js
4726
- import path18 from "node:path";
5401
+ import fs18 from "node:fs/promises";
5402
+ import path20 from "node:path";
4727
5403
 
4728
5404
  // dist/branch-metadata.js
4729
- import fs15 from "node:fs/promises";
5405
+ import fs14 from "node:fs/promises";
4730
5406
  import path16 from "node:path";
4731
5407
 
4732
5408
  // dist/branch-registry.js
4733
- import fs13 from "node:fs/promises";
5409
+ import fs12 from "node:fs/promises";
4734
5410
  import path14 from "node:path";
4735
5411
 
4736
5412
  // dist/branch-metadata-file.js
4737
- import fs12 from "node:fs/promises";
5413
+ import fs11 from "node:fs/promises";
4738
5414
  import path13 from "node:path";
4739
5415
  var BRANCH_META_DIR = ".canopy-meta";
4740
5416
  var BRANCH_META_FILE = "branch.json";
@@ -4750,7 +5426,7 @@ var branchMetadataFilePath = (branchRoot) => path13.join(path13.resolve(branchRo
4750
5426
  async function readBranchMetadataFile(branchRoot) {
4751
5427
  const resolvedRoot = path13.resolve(branchRoot);
4752
5428
  try {
4753
- const raw = await fs12.readFile(branchMetadataFilePath(resolvedRoot), "utf8");
5429
+ const raw = await fs11.readFile(branchMetadataFilePath(resolvedRoot), "utf8");
4754
5430
  return JSON.parse(raw);
4755
5431
  } catch (err) {
4756
5432
  if (isNotFoundError(err)) {
@@ -4765,7 +5441,7 @@ async function readBranchMetadataFile(branchRoot) {
4765
5441
 
4766
5442
  // dist/branch-registry.js
4767
5443
  init_logger();
4768
- var log3 = createDebugLogger({ prefix: "BranchRegistry" });
5444
+ var log5 = createDebugLogger({ prefix: "BranchRegistry" });
4769
5445
  var REGISTRY_FILE = "branches.json";
4770
5446
  var REGISTRY_STALE_FILE = "branches.stale.json";
4771
5447
  var REGISTRY_TEMP_FILE = "branches.tmp.json";
@@ -4788,7 +5464,7 @@ var BranchRegistry = class {
4788
5464
  async list() {
4789
5465
  let parsed;
4790
5466
  try {
4791
- const raw = await fs13.readFile(this.registryPath, "utf8");
5467
+ const raw = await fs12.readFile(this.registryPath, "utf8");
4792
5468
  parsed = JSON.parse(raw);
4793
5469
  } catch (err) {
4794
5470
  if (isNotFoundError(err)) {
@@ -4845,7 +5521,7 @@ var BranchRegistry = class {
4845
5521
  });
4846
5522
  await this.regenerate();
4847
5523
  } catch (err) {
4848
- log3.warn("invalidate", "Eager regeneration after invalidate() failed", {
5524
+ log5.warn("invalidate", "Eager regeneration after invalidate() failed", {
4849
5525
  error: getErrorMessage(err)
4850
5526
  });
4851
5527
  }
@@ -4870,7 +5546,7 @@ var BranchRegistry = class {
4870
5546
  async performRegenerate() {
4871
5547
  const read = await readResourceGeneration(this.root, RESOURCE2);
4872
5548
  const branches = await this.scanBranchDirectories();
4873
- await fs13.unlink(this.stalePath).catch(() => {
5549
+ await fs12.unlink(this.stalePath).catch(() => {
4874
5550
  });
4875
5551
  if (!read.ok) {
4876
5552
  return branches;
@@ -4881,12 +5557,12 @@ var BranchRegistry = class {
4881
5557
  generation: read.token
4882
5558
  };
4883
5559
  const uniqueTempPath = `${this.tempPath}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
4884
- await fs13.mkdir(this.root, { recursive: true });
4885
- await fs13.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
5560
+ await fs12.mkdir(this.root, { recursive: true });
5561
+ await fs12.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
4886
5562
  try {
4887
- await fs13.rename(uniqueTempPath, this.registryPath);
5563
+ await fs12.rename(uniqueTempPath, this.registryPath);
4888
5564
  } catch (err) {
4889
- await fs13.unlink(uniqueTempPath).catch(() => {
5565
+ await fs12.unlink(uniqueTempPath).catch(() => {
4890
5566
  });
4891
5567
  throw err;
4892
5568
  }
@@ -4900,7 +5576,7 @@ var BranchRegistry = class {
4900
5576
  async scanBranchDirectories() {
4901
5577
  const branches = [];
4902
5578
  try {
4903
- const entries = await fs13.readdir(this.root, { withFileTypes: true });
5579
+ const entries = await fs12.readdir(this.root, { withFileTypes: true });
4904
5580
  for (const entry of entries) {
4905
5581
  if (!entry.isDirectory() || entry.name.startsWith(".")) {
4906
5582
  continue;
@@ -4932,11 +5608,11 @@ var BranchRegistry = class {
4932
5608
  };
4933
5609
 
4934
5610
  // dist/utils/occ-json-write.js
4935
- import fs14 from "node:fs/promises";
5611
+ import fs13 from "node:fs/promises";
4936
5612
  import path15 from "node:path";
4937
5613
  import { randomUUID as randomUUID2 } from "node:crypto";
4938
5614
  import lockfile2 from "proper-lockfile";
4939
- var log4 = createDebugLogger({ prefix: "OccJsonWrite" });
5615
+ var log6 = createDebugLogger({ prefix: "OccJsonWrite" });
4940
5616
  var OccWriteConflictError = class extends Error {
4941
5617
  constructor(message = "Concurrent modification detected") {
4942
5618
  super(message);
@@ -4950,28 +5626,28 @@ async function writeOccJsonFile(filePath, payload, options) {
4950
5626
  const data = { ...payload, version: newVersion, writeId };
4951
5627
  const content = JSON.stringify(data, null, 2) + (trailingNewline ? "\n" : "");
4952
5628
  const dir = path15.dirname(filePath);
4953
- await fs14.mkdir(dir, { recursive: true });
5629
+ await fs13.mkdir(dir, { recursive: true });
4954
5630
  const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
4955
- await fs14.writeFile(tempPath, content, "utf-8");
5631
+ await fs13.writeFile(tempPath, content, "utf-8");
4956
5632
  if (expectedVersion === null) {
4957
5633
  try {
4958
- await fs14.link(tempPath, filePath);
5634
+ await fs13.link(tempPath, filePath);
4959
5635
  } catch (err) {
4960
- await fs14.unlink(tempPath).catch(() => {
5636
+ await fs13.unlink(tempPath).catch(() => {
4961
5637
  });
4962
5638
  if (isFileExistsError(err)) {
4963
5639
  throw new OccWriteConflictError();
4964
5640
  }
4965
5641
  throw err;
4966
5642
  }
4967
- await fs14.unlink(tempPath).catch(() => {
5643
+ await fs13.unlink(tempPath).catch(() => {
4968
5644
  });
4969
5645
  return { version: newVersion, writeId };
4970
5646
  }
4971
5647
  try {
4972
5648
  let currentVersion = null;
4973
5649
  try {
4974
- const current = JSON.parse(await fs14.readFile(filePath, "utf-8"));
5650
+ const current = JSON.parse(await fs13.readFile(filePath, "utf-8"));
4975
5651
  currentVersion = current.version ?? 0;
4976
5652
  } catch {
4977
5653
  currentVersion = null;
@@ -4979,16 +5655,16 @@ async function writeOccJsonFile(filePath, payload, options) {
4979
5655
  if (currentVersion !== expectedVersion) {
4980
5656
  throw new OccWriteConflictError();
4981
5657
  }
4982
- await fs14.rename(tempPath, filePath);
5658
+ await fs13.rename(tempPath, filePath);
4983
5659
  if (settleMs > 0) {
4984
5660
  await new Promise((resolve2) => setTimeout(resolve2, settleMs));
4985
5661
  }
4986
- const afterWrite = JSON.parse(await fs14.readFile(filePath, "utf-8"));
5662
+ const afterWrite = JSON.parse(await fs13.readFile(filePath, "utf-8"));
4987
5663
  if (afterWrite.writeId !== writeId) {
4988
5664
  throw new OccWriteConflictError();
4989
5665
  }
4990
5666
  } catch (err) {
4991
- await fs14.unlink(tempPath).catch(() => {
5667
+ await fs13.unlink(tempPath).catch(() => {
4992
5668
  });
4993
5669
  throw err;
4994
5670
  }
@@ -5014,8 +5690,13 @@ async function withOccRetry(operation, options) {
5014
5690
  throw new Error("Unreachable");
5015
5691
  }
5016
5692
  async function withOccFileLock(filePath, fn) {
5017
- const dir = path15.dirname(filePath);
5018
- await fs14.mkdir(dir, { recursive: true });
5693
+ try {
5694
+ await fs13.mkdir(path15.dirname(filePath));
5695
+ } catch (err) {
5696
+ if (!isNodeError(err) || err.code !== "EEXIST") {
5697
+ throw new OccWriteConflictError(`Could not acquire file lock: ${getErrorMessage(err)}`);
5698
+ }
5699
+ }
5019
5700
  const MAX_ATTEMPTS = 14;
5020
5701
  let release = null;
5021
5702
  for (let attempt = 1; release === null; attempt++) {
@@ -5031,7 +5712,7 @@ async function withOccFileLock(filePath, fn) {
5031
5712
  // Our critical sections are short and idempotent-on-conflict (OCC
5032
5713
  // verify inside); log and let the section finish.
5033
5714
  onCompromised: (err) => {
5034
- log4.warn("lock", `Lock compromised mid-hold for ${filePath}`, {
5715
+ log6.warn("lock", `Lock compromised mid-hold for ${filePath}`, {
5035
5716
  error: getErrorMessage(err)
5036
5717
  });
5037
5718
  }
@@ -5053,7 +5734,7 @@ async function withOccFileLock(filePath, fn) {
5053
5734
  await release().catch((err) => {
5054
5735
  if (isNodeError(err) && err.code === "ENOENT")
5055
5736
  return;
5056
- log4.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage(err) });
5737
+ log6.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage(err) });
5057
5738
  });
5058
5739
  }
5059
5740
  }
@@ -5086,7 +5767,7 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
5086
5767
  }
5087
5768
  async load() {
5088
5769
  try {
5089
- const raw = await fs15.readFile(this.filePath, "utf8");
5770
+ const raw = await fs14.readFile(this.filePath, "utf8");
5090
5771
  const parsed = JSON.parse(raw);
5091
5772
  const version = parsed.version ?? 0;
5092
5773
  return { meta: parsed, version };
@@ -5132,15 +5813,13 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
5132
5813
  * entry with no clone behind it. It runs BEFORE the lock stack so a doomed
5133
5814
  * save fails fast instead of paying for a lock.
5134
5815
  *
5135
- * Accepted residual window: a save that passes the check can still race a
5136
- * `rm` that starts moments later and is mid-flight when the write lands.
5137
- * Closing that needs a tombstone OUTSIDE the tree being removed, and the
5138
- * lockfile taken next lives INSIDE `branchRoot`, so it can promise no more
5139
- * than "the directory existed a moment ago".
5816
+ * A save that passes the check just before a delete still cannot write: the
5817
+ * delete renames the tree away under the same lockfile, and that lockfile
5818
+ * creates only its own directory (`withOccFileLock`), so the save fails there.
5140
5819
  */
5141
5820
  async save(incoming) {
5142
5821
  try {
5143
- await fs15.stat(this.branchRoot);
5822
+ await fs14.stat(this.branchRoot);
5144
5823
  } catch (err) {
5145
5824
  if (isNotFoundError(err)) {
5146
5825
  throw new BranchMetadataConflictError("Branch no longer exists");
@@ -5151,37 +5830,7 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
5151
5830
  try {
5152
5831
  saved = await withLock(this.filePath, () => withOccFileLock(this.filePath, () => withOccRetry(async () => {
5153
5832
  const { meta: existing, version } = await this.load();
5154
- const now = (/* @__PURE__ */ new Date()).toISOString();
5155
- const defaults = {
5156
- name: "unknown",
5157
- status: "editing",
5158
- access: {},
5159
- createdBy: "unknown",
5160
- createdAt: now,
5161
- updatedAt: now
5162
- };
5163
- const merged = {
5164
- schemaVersion: CURRENT_SCHEMA_VERSION,
5165
- version: version ?? 0,
5166
- branch: {
5167
- ...defaults,
5168
- ...existing?.branch,
5169
- ...incoming.branch,
5170
- access: {
5171
- ...existing?.branch?.access,
5172
- ...incoming.branch?.access
5173
- },
5174
- // Immutable after creation
5175
- createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
5176
- createdAt: existing?.branch.createdAt ?? defaults.createdAt,
5177
- // Fork point is recorded once at creation; later saves must not move it
5178
- baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch,
5179
- // Always stamped fresh; the spreads above would otherwise let
5180
- // the creation-time value win forever, freezing the timestamp
5181
- // the editor's Branches panel sorts and displays by
5182
- updatedAt: now
5183
- }
5184
- };
5833
+ const merged = mergeBranchMetadata(existing, version, incoming);
5185
5834
  const written = await this.write(merged, version);
5186
5835
  merged.version = written.version;
5187
5836
  merged.writeId = written.writeId;
@@ -5202,9 +5851,40 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
5202
5851
  await registry2.invalidate();
5203
5852
  }
5204
5853
  };
5205
- var getBranchMetadataFileManager = (branchRoot, baseRoot, options) => {
5206
- return BranchMetadataFileManager.get(branchRoot, baseRoot, options);
5207
- };
5854
+ function mergeBranchMetadata(existing, version, incoming, now = (/* @__PURE__ */ new Date()).toISOString()) {
5855
+ const defaults = {
5856
+ name: "unknown",
5857
+ status: "editing",
5858
+ access: {},
5859
+ createdBy: "unknown",
5860
+ createdAt: now,
5861
+ updatedAt: now
5862
+ };
5863
+ return {
5864
+ schemaVersion: CURRENT_SCHEMA_VERSION,
5865
+ version: version ?? 0,
5866
+ branch: {
5867
+ ...defaults,
5868
+ ...existing?.branch,
5869
+ ...incoming.branch,
5870
+ access: {
5871
+ ...existing?.branch?.access,
5872
+ ...incoming.branch?.access
5873
+ },
5874
+ // Immutable after creation
5875
+ createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
5876
+ createdAt: existing?.branch.createdAt ?? defaults.createdAt,
5877
+ // Fork point is recorded once at creation; later saves must not move it
5878
+ baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch,
5879
+ // Always stamped fresh; the spreads above would otherwise let the creation-time value win
5880
+ // forever, freezing the timestamp the editor's Branches panel sorts and displays by
5881
+ updatedAt: now
5882
+ }
5883
+ };
5884
+ }
5885
+ function buildInitialBranchMetadata(incoming, now) {
5886
+ return mergeBranchMetadata(null, null, incoming, now);
5887
+ }
5208
5888
  var loadBranchContext = async (options) => {
5209
5889
  const { branchRoot, baseRoot } = resolveBranchPath({
5210
5890
  branchName: options.branchName,
@@ -5222,89 +5902,140 @@ var loadBranchContext = async (options) => {
5222
5902
  };
5223
5903
  };
5224
5904
 
5225
- // dist/branch-workspace.js
5226
- init_operating_mode();
5905
+ // dist/branch-provisioning.js
5906
+ import { createHash, randomBytes } from "node:crypto";
5907
+ import fs16 from "node:fs/promises";
5908
+ import path18 from "node:path";
5227
5909
 
5228
5910
  // dist/git-manager.js
5229
- import fs16 from "node:fs/promises";
5911
+ import fs15 from "node:fs/promises";
5230
5912
  import path17 from "node:path";
5231
5913
  import { simpleGit as simpleGit2 } from "simple-git";
5232
-
5233
- // dist/utils/git.js
5234
- import { simpleGit } from "simple-git";
5235
- var NETWORK_SCHEME_PATTERN = /^(https?|ssh|git):\/\//i;
5236
- var TRANSPORT_HELPER_PATTERN = /^[a-z][a-z0-9+.-]*::/i;
5237
- var LEADING_OPTION_PATTERN = /^-/;
5238
- var WINDOWS_DRIVE_PATTERN = /^[a-zA-Z]:[\\/]/;
5239
- var COLON_BEFORE_SLASH_PATTERN = /^[^/\\\s]+:/;
5240
- function isNetworkRemoteUrl(url) {
5241
- if (/^file:\/\//i.test(url))
5242
- return false;
5243
- if (NETWORK_SCHEME_PATTERN.test(url))
5244
- return true;
5245
- if (TRANSPORT_HELPER_PATTERN.test(url))
5246
- return true;
5247
- if (LEADING_OPTION_PATTERN.test(url))
5248
- return true;
5249
- if (WINDOWS_DRIVE_PATTERN.test(url))
5250
- return false;
5251
- return COLON_BEFORE_SLASH_PATTERN.test(url);
5252
- }
5253
- var MISSING_REMOTE_REF_REASONS = ["couldn't find remote ref", "Couldn't find remote ref"];
5254
- function isMissingRemoteRefFailure(message) {
5255
- return MISSING_REMOTE_REF_REASONS.some((reason) => message.includes(reason));
5256
- }
5257
- async function detectHeadBranch(repoRoot, fallback = "main") {
5258
- try {
5259
- const git = simpleGit({ baseDir: repoRoot });
5260
- const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
5261
- return head && head !== "HEAD" ? head : fallback;
5262
- } catch {
5263
- return fallback;
5914
+ init_logger();
5915
+ var log7 = createDebugLogger({ prefix: "GitManager" });
5916
+ var GIT_ENV_PASSTHROUGH = /^(PATH|HOME|USER|LANG|LC_[A-Z]+|TZ|TMPDIR|GIT_(AUTHOR|COMMITTER)_(NAME|EMAIL|DATE)|GIT_TERMINAL_PROMPT|GIT_TRACE[0-9A-Z_]*)$/;
5917
+ var FORCE_C_LOCALE = { LC_ALL: "C", LANG: "C" };
5918
+ function gitChildEnv(overrides) {
5919
+ const env = {};
5920
+ for (const [key, value] of Object.entries(process.env)) {
5921
+ if (value !== void 0 && GIT_ENV_PASSTHROUGH.test(key))
5922
+ env[key] = value;
5264
5923
  }
5924
+ return { ...env, ...FORCE_C_LOCALE, ...overrides };
5265
5925
  }
5266
- async function resolveBaseBranch(options) {
5267
- if (options.defaultBaseBranch)
5268
- return options.defaultBaseBranch;
5269
- if (options.mode === "dev") {
5270
- return detectHeadBranch(options.detectFrom ?? process.cwd());
5271
- }
5272
- return "main";
5273
- }
5274
-
5275
- // dist/git-manager.js
5276
- var log5 = createDebugLogger({ prefix: "GitManager" });
5277
- var GIT_ENV_PASSTHROUGH = /^(PATH|HOME|USER|LANG|LC_[A-Z]+|TZ|TMPDIR|GIT_(AUTHOR|COMMITTER)_(NAME|EMAIL|DATE)|GIT_TERMINAL_PROMPT|GIT_TRACE[0-9A-Z_]*)$/;
5278
- var FORCE_C_LOCALE = { LC_ALL: "C", LANG: "C" };
5279
- function gitChildEnv(overrides) {
5926
+ var GIT_NETWORK_ENV_PASSTHROUGH = /^((HTTPS?|ALL)_PROXY|(https?|all)_proxy|NO_PROXY|no_proxy|GIT_SSL_(CAINFO|CAPATH|NO_VERIFY|VERSION)|CURL_CA_BUNDLE|SSL_CERT_(FILE|DIR)|REQUESTS_CA_BUNDLE|NODE_EXTRA_CA_CERTS)$/;
5927
+ function gitNetworkChildEnv() {
5280
5928
  const env = {};
5281
5929
  for (const [key, value] of Object.entries(process.env)) {
5282
- if (value !== void 0 && GIT_ENV_PASSTHROUGH.test(key))
5930
+ if (value === void 0)
5931
+ continue;
5932
+ if (GIT_ENV_PASSTHROUGH.test(key) || GIT_NETWORK_ENV_PASSTHROUGH.test(key))
5283
5933
  env[key] = value;
5284
5934
  }
5285
- return { ...env, ...FORCE_C_LOCALE, ...overrides };
5935
+ return { ...env, ...FORCE_C_LOCALE };
5936
+ }
5937
+ var NO_AUTO_GC_CONFIG = ["gc.auto=0", "maintenance.auto=false"];
5938
+ var REMOTE_GIT_CONFIG = [
5939
+ ["gc.auto", "0"],
5940
+ ["receive.autogc", "false"],
5941
+ ["maintenance.auto", "false"],
5942
+ ["transfer.unpackLimit", "1"]
5943
+ ];
5944
+ async function ensureRemoteGitConfig(gitDir) {
5945
+ const git = simpleGit2().env(gitChildEnv({}));
5946
+ const current = /* @__PURE__ */ new Map();
5947
+ const listed = await git.raw(["--git-dir", gitDir, "config", "--local", "--list"]);
5948
+ for (const line of listed.split("\n")) {
5949
+ const eq = line.indexOf("=");
5950
+ if (eq > 0)
5951
+ current.set(line.slice(0, eq).toLowerCase(), line.slice(eq + 1));
5952
+ }
5953
+ for (const [key, value] of REMOTE_GIT_CONFIG) {
5954
+ if (current.get(key.toLowerCase()) === value)
5955
+ continue;
5956
+ await git.raw(["--git-dir", gitDir, "config", "--local", key, value]);
5957
+ }
5958
+ }
5959
+ var REMOTE_GIT_MAX_LOOSE_OBJECTS = 50;
5960
+ var REMOTE_GIT_MAX_PACKS = 6;
5961
+ async function countObjects(git, gitDir) {
5962
+ const output = await git.raw(["--git-dir", gitDir, "count-objects", "-v"]);
5963
+ const fields = new Map(output.split("\n").map((line) => {
5964
+ const [name, value = ""] = line.split(": ");
5965
+ return [name, value.trim()];
5966
+ }));
5967
+ const field = (name) => {
5968
+ const value = fields.get(name);
5969
+ if (!value || !/^\d+$/.test(value)) {
5970
+ throw new Error(`count-objects -v printed no '${name}' count: ${output.trim()}`);
5971
+ }
5972
+ return Number(value);
5973
+ };
5974
+ return { loose: field("count"), packs: field("packs") };
5975
+ }
5976
+ async function repackBareRemoteIfNeeded(gitDir) {
5977
+ const git = simpleGit2().env(gitChildEnv({}));
5978
+ const before = await countObjects(git, gitDir);
5979
+ if (before.loose <= REMOTE_GIT_MAX_LOOSE_OBJECTS && before.packs <= REMOTE_GIT_MAX_PACKS) {
5980
+ return { repacked: false, before };
5981
+ }
5982
+ const startedAt = Date.now();
5983
+ await git.raw(["--git-dir", gitDir, "repack", "-a", "-d", "--cruft", "-q"]);
5984
+ await git.raw(["--git-dir", gitDir, "pack-refs", "--all"]);
5985
+ const after = await countObjects(git, gitDir);
5986
+ return { repacked: true, before, after, ms: Date.now() - startedAt };
5987
+ }
5988
+ function failOnSignalExit(error, result) {
5989
+ if (error)
5990
+ return error;
5991
+ if (result.exitCode === null) {
5992
+ return new Error("git was killed by a signal before it finished");
5993
+ }
5994
+ return void 0;
5995
+ }
5996
+ var PARALLEL_CHECKOUT_ARGS = [
5997
+ "-c",
5998
+ "checkout.workers=8",
5999
+ "-c",
6000
+ "checkout.thresholdForParallelism=1"
6001
+ ];
6002
+ async function dirState(dir) {
6003
+ try {
6004
+ return (await fs15.readdir(dir)).length === 0 ? "empty" : "occupied";
6005
+ } catch (err) {
6006
+ if (isNodeError(err) && err.code === "ENOENT")
6007
+ return "absent";
6008
+ throw err;
6009
+ }
6010
+ }
6011
+ function managedWorkspaceConfig(gitBotAuthorName, gitBotAuthorEmail) {
6012
+ return {
6013
+ "canopycms.managed": "true",
6014
+ "user.name": gitBotAuthorName,
6015
+ "user.email": gitBotAuthorEmail
6016
+ };
5286
6017
  }
5287
6018
  var remoteInitLocks = /* @__PURE__ */ new Map();
5288
6019
  var GITHUB_TRACKING_REF_PREFIX = "refs/remotes/github/";
5289
6020
  async function ensureGitExcludePattern(repoPath, pattern) {
5290
6021
  const excludePath = path17.join(repoPath, ".git", "info", "exclude");
5291
- await fs16.mkdir(path17.dirname(excludePath), { recursive: true });
6022
+ await fs15.mkdir(path17.dirname(excludePath), { recursive: true });
5292
6023
  let content = "";
5293
6024
  try {
5294
- content = await fs16.readFile(excludePath, "utf-8");
6025
+ content = await fs15.readFile(excludePath, "utf-8");
5295
6026
  } catch (err) {
5296
6027
  if (!isNotFoundError(err))
5297
6028
  throw err;
5298
6029
  }
5299
6030
  const lines = content.split("\n");
5300
6031
  if (lines.some((line) => line.trim() === pattern)) {
5301
- log5.debug("git", "Pattern already in .git/info/exclude", { pattern });
6032
+ log7.debug("git", "Pattern already in .git/info/exclude", { pattern });
5302
6033
  return;
5303
6034
  }
5304
6035
  const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
5305
6036
  const newContent = content + (needsLeadingNewline ? "\n" : "") + pattern + "\n";
5306
- await fs16.writeFile(excludePath, newContent, "utf-8");
5307
- log5.debug("git", "Added pattern to .git/info/exclude", { pattern });
6037
+ await fs15.writeFile(excludePath, newContent, "utf-8");
6038
+ log7.debug("git", "Added pattern to .git/info/exclude", { pattern });
5308
6039
  }
5309
6040
  var GitConflictError = class extends Error {
5310
6041
  constructor(conflictedFiles) {
@@ -5322,24 +6053,95 @@ var GitRemoteRefMissingError = class extends Error {
5322
6053
  this.name = "GitRemoteRefMissingError";
5323
6054
  }
5324
6055
  };
6056
+ var RemoteNotReadyError = class extends Error {
6057
+ constructor(expectedRemotePath) {
6058
+ super(`CanopyCMS: no git remote is available yet. defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is not set and the CMS worker has not created ${expectedRemotePath}; the worker may still be starting.`);
6059
+ this.expectedRemotePath = expectedRemotePath;
6060
+ this.name = "RemoteNotReadyError";
6061
+ }
6062
+ };
6063
+ var SettingsRemoteUnreadableError = class extends Error {
6064
+ constructor(branch, remote, gitError) {
6065
+ super(`CanopyCMS: could not read settings branch '${branch}' from remote '${remote}', so the settings workspace was not provisioned: ${getErrorMessage(gitError)}`);
6066
+ this.branch = branch;
6067
+ this.remote = remote;
6068
+ this.gitError = gitError;
6069
+ this.name = "SettingsRemoteUnreadableError";
6070
+ }
6071
+ };
6072
+ var SettingsBranchDivergedError = class extends Error {
6073
+ constructor(branch, workspacePath, localWork) {
6074
+ super(`CanopyCMS: settings branch '${branch}' in ${workspacePath} shares no history with the remote's '${branch}', and holds ${localWork}. Settings saves cannot pull until one copy is chosen. To keep the remote's settings: copy anything you need out of ${workspacePath}, move it aside, and restart; the next start checks out the remote's '${branch}', and copied changes can then be re-applied in the editor.`);
6075
+ this.branch = branch;
6076
+ this.workspacePath = workspacePath;
6077
+ this.localWork = localWork;
6078
+ this.name = "SettingsBranchDivergedError";
6079
+ }
6080
+ };
6081
+ var SETTINGS_BRANCH_FILES = /* @__PURE__ */ new Set(["permissions.json", "groups.json"]);
6082
+ var SettingsBranchHasContentHistoryError = class extends Error {
6083
+ constructor(branch, remote, evidence) {
6084
+ super(`CanopyCMS: the settings branch '${branch}' on remote '${remote}' holds content, not settings (${evidence}), so it was not checked out. Restore '${branch}' on the remote from a copy of the settings (it may hold only permissions.json and groups.json), then restart.`);
6085
+ this.branch = branch;
6086
+ this.remote = remote;
6087
+ this.evidence = evidence;
6088
+ this.name = "SettingsBranchHasContentHistoryError";
6089
+ }
6090
+ };
5325
6091
  var GitManager = class _GitManager {
5326
6092
  constructor(options, gitOptions) {
5327
6093
  this.repoPath = path17.resolve(options.repoPath);
5328
6094
  this.baseBranch = options.baseBranch ?? "main";
5329
6095
  this.remote = options.remote ?? "origin";
5330
6096
  this.skipIndexMarker = options.skipIndexMarker ?? false;
5331
- this.git = simpleGit2({ baseDir: this.repoPath, ...gitOptions });
6097
+ this.git = simpleGit2({
6098
+ baseDir: this.repoPath,
6099
+ errors: failOnSignalExit,
6100
+ ...gitOptions,
6101
+ config: [...NO_AUTO_GC_CONFIG, ...gitOptions?.config ?? []]
6102
+ });
5332
6103
  this.git.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path17.dirname(this.repoPath) }));
5333
6104
  }
5334
- static async cloneRepo(remoteUrl, targetPath, baseBranch = "main") {
5335
- log5.debug("git", "Cloning repository", {
6105
+ /**
6106
+ * Single-branch clone of `baseBranch`, retried once: a clone can lose a race
6107
+ * with the worker's repack of `remote.git` deleting a pack it had just
6108
+ * listed. Before the retry it clears only what it created itself, so a
6109
+ * target that held files beforehand is never touched.
6110
+ */
6111
+ static async cloneRepo(remoteUrl, targetPath, baseBranch = "main", options = {}) {
6112
+ log7.debug("git", "Cloning repository", {
5336
6113
  remoteUrl,
5337
6114
  targetPath,
5338
6115
  baseBranch
5339
6116
  });
5340
- const git = simpleGit2();
5341
- await git.clone(remoteUrl, targetPath, ["--branch", baseBranch, "--single-branch"]);
5342
- log5.debug("git", "Clone complete");
6117
+ const args = ["--single-branch", "--branch", baseBranch];
6118
+ if (options.noCheckout)
6119
+ args.push("--no-checkout");
6120
+ if (options.remoteName)
6121
+ args.push("--origin", options.remoteName);
6122
+ for (const [key, value] of Object.entries(options.config ?? {})) {
6123
+ args.push("-c", `${key}=${value}`);
6124
+ }
6125
+ for (const setting of NO_AUTO_GC_CONFIG)
6126
+ args.push("-c", setting);
6127
+ const git = simpleGit2({ config: [...NO_AUTO_GC_CONFIG], errors: failOnSignalExit });
6128
+ git.env({
6129
+ ...isNetworkRemoteUrl(remoteUrl) ? gitNetworkChildEnv() : gitChildEnv({}),
6130
+ GIT_CEILING_DIRECTORIES: path17.dirname(targetPath)
6131
+ });
6132
+ const before = await dirState(targetPath);
6133
+ try {
6134
+ await git.clone(remoteUrl, targetPath, args);
6135
+ } catch (err) {
6136
+ if (before === "occupied")
6137
+ throw err;
6138
+ canopyLogWarn(`[canopy] clone into ${targetPath} failed, retrying once: ` + redactCredentials(getErrorMessage(err)));
6139
+ await fs15.rm(targetPath, { recursive: true, force: true });
6140
+ if (before === "empty")
6141
+ await fs15.mkdir(targetPath);
6142
+ await git.clone(remoteUrl, targetPath, args);
6143
+ }
6144
+ log7.debug("git", "Clone complete");
5343
6145
  }
5344
6146
  /**
5345
6147
  * Initializes a local bare git repository to simulate a remote for dev mode,
@@ -5353,15 +6155,15 @@ var GitManager = class _GitManager {
5353
6155
  static async ensureLocalSimulatedRemote(options) {
5354
6156
  const existingLock = remoteInitLocks.get(options.remotePath);
5355
6157
  if (existingLock) {
5356
- log5.debug("git", "Waiting for existing remote initialization", {
6158
+ log7.debug("git", "Waiting for existing remote initialization", {
5357
6159
  remotePath: options.remotePath
5358
6160
  });
5359
6161
  await existingLock;
5360
6162
  }
5361
- const lockPromise = log5.timed("git", "ensureLocalSimulatedRemote", async () => {
6163
+ const lockPromise = log7.timed("git", "ensureLocalSimulatedRemote", async () => {
5362
6164
  let releaseLock;
5363
6165
  try {
5364
- log5.debug("git", "Initializing local simulated remote", {
6166
+ log7.debug("git", "Initializing local simulated remote", {
5365
6167
  remotePath: options.remotePath,
5366
6168
  baseBranch: options.baseBranch
5367
6169
  });
@@ -5369,14 +6171,26 @@ var GitManager = class _GitManager {
5369
6171
  releaseLock = await acquireProvisioningLock(remoteParent, ".remote-init.lock");
5370
6172
  let remoteExists = false;
5371
6173
  try {
5372
- const stat = await fs16.stat(options.remotePath);
6174
+ const stat = await fs15.stat(options.remotePath);
5373
6175
  remoteExists = stat.isDirectory();
5374
6176
  } catch (err) {
5375
6177
  if (!isNotFoundError(err))
5376
6178
  throw err;
5377
6179
  }
6180
+ if (remoteExists) {
6181
+ try {
6182
+ const repack = await repackBareRemoteIfNeeded(options.remotePath);
6183
+ if (repack.repacked)
6184
+ log7.debug("git", "Repacked local simulated remote", repack);
6185
+ } catch (err) {
6186
+ log7.debug("git", "Could not repack local simulated remote", {
6187
+ error: getErrorMessage(err)
6188
+ });
6189
+ }
6190
+ }
5378
6191
  if (remoteExists && await _GitManager.bareRemoteHasBranch(options.remotePath, options.baseBranch)) {
5379
- log5.debug("git", "Remote already has base branch, skipping");
6192
+ log7.debug("git", "Remote already has base branch, skipping");
6193
+ await ensureRemoteGitConfig(options.remotePath);
5380
6194
  return;
5381
6195
  }
5382
6196
  let gitRoot = options.sourcePath;
@@ -5395,8 +6209,8 @@ var GitManager = class _GitManager {
5395
6209
  }
5396
6210
  let hasCommits = false;
5397
6211
  try {
5398
- const log8 = await sourceGit.log(["-1"]);
5399
- hasCommits = log8.total > 0;
6212
+ const log9 = await sourceGit.log(["-1"]);
6213
+ hasCommits = log9.total > 0;
5400
6214
  } catch {
5401
6215
  hasCommits = false;
5402
6216
  }
@@ -5408,12 +6222,12 @@ var GitManager = class _GitManager {
5408
6222
  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.`);
5409
6223
  }
5410
6224
  if (remoteExists) {
5411
- log5.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
6225
+ log7.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
5412
6226
  remotePath: options.remotePath,
5413
6227
  baseBranch: options.baseBranch
5414
6228
  });
5415
6229
  } else {
5416
- log5.debug("git", "Creating bare remote repository");
6230
+ log7.debug("git", "Creating bare remote repository");
5417
6231
  await simpleGit2().raw([
5418
6232
  "init",
5419
6233
  "--bare",
@@ -5421,19 +6235,20 @@ var GitManager = class _GitManager {
5421
6235
  options.remotePath
5422
6236
  ]);
5423
6237
  }
6238
+ await ensureRemoteGitConfig(options.remotePath);
5424
6239
  await _GitManager.pushBranchToLocalRemote({
5425
6240
  sourceGit,
5426
6241
  remotePath: options.remotePath,
5427
6242
  baseBranch: options.baseBranch,
5428
6243
  subdirectory: options.subdirectory
5429
6244
  });
5430
- log5.debug("git", "Remote initialization complete");
6245
+ log7.debug("git", "Remote initialization complete");
5431
6246
  } finally {
5432
6247
  if (releaseLock) {
5433
6248
  try {
5434
6249
  await releaseLock();
5435
6250
  } catch (err) {
5436
- log5.debug("git", "Failed to release remote-init lock", { err });
6251
+ log7.debug("git", "Failed to release remote-init lock", { err });
5437
6252
  }
5438
6253
  }
5439
6254
  remoteInitLocks.delete(options.remotePath);
@@ -5501,7 +6316,7 @@ var GitManager = class _GitManager {
5501
6316
  * deleteBranchHandler: a head left in `remote.git` forever makes the
5502
6317
  * create -> publish -> squash-merge -> delete -> reuse-the-name cycle reject
5503
6318
  * the reused branch's first publish non-fast-forward against the stale head
5504
- * (`GitManager.push()` pushes `branch:branch`, and a squash-merged old tip is
6319
+ * (`GitManager.push()` pushes the branch to the same name, and a squash-merged old tip is
5505
6320
  * not an ancestor of the new branch), and a retried submit then skips the
5506
6321
  * local push on a clean tree and enqueues the worker push of the STALE head,
5507
6322
  * resurrecting the deleted branch's content on GitHub as an apparent success.
@@ -5601,7 +6416,7 @@ var GitManager = class _GitManager {
5601
6416
  */
5602
6417
  static async validateGitRepoExists(repoPath) {
5603
6418
  try {
5604
- const stat = await fs16.stat(path17.join(repoPath, ".git"));
6419
+ const stat = await fs15.stat(path17.join(repoPath, ".git"));
5605
6420
  if (!stat.isDirectory()) {
5606
6421
  throw new Error(`Expected git repo at ${repoPath}`);
5607
6422
  }
@@ -5632,6 +6447,12 @@ var GitManager = class _GitManager {
5632
6447
  return;
5633
6448
  throw new Error(`CanopyCMS: refusing to use a network remote URL in prod mode (from ${source}: "${url}"). The standard AWS Lambda+EC2-worker topology runs the CMS Lambda with no internet access \u2014 the EC2 worker owns all network git I/O, and the Lambda is expected to reach the EFS-local bare repo ({workspace}/remote.git) via auto-detect instead. Pointing a network URL here would make the internet-less Lambda try to clone/fetch/push it directly and hang until timeout. If this prod host genuinely has internet access and intentionally runs git against a network remote (e.g. a single-VM deployment), set config.allowNetworkRemoteInProd: true to acknowledge this.`);
5634
6449
  }
6450
+ /** The not-ready error for a mode whose remote is auto-detected, or `undefined` for other modes. */
6451
+ static async remoteNotReadyError(mode) {
6452
+ const { operatingStrategy: operatingStrategy2 } = await Promise.resolve().then(() => (init_operating_mode(), operating_mode_exports));
6453
+ const { autoDetectRemotePath } = operatingStrategy2(mode).getRemoteUrlConfig();
6454
+ return autoDetectRemotePath ? new RemoteNotReadyError(autoDetectRemotePath) : void 0;
6455
+ }
5635
6456
  /**
5636
6457
  * Resolves the remote URL for git operations following the priority:
5637
6458
  * 1. Explicit remoteUrl parameter
@@ -5666,9 +6487,9 @@ var GitManager = class _GitManager {
5666
6487
  }
5667
6488
  if (config.autoDetectRemotePath) {
5668
6489
  try {
5669
- const stat = await fs16.stat(config.autoDetectRemotePath);
6490
+ const stat = await fs15.stat(config.autoDetectRemotePath);
5670
6491
  if (stat.isDirectory()) {
5671
- log5.debug("git", "Auto-detected local remote", {
6492
+ log7.debug("git", "Auto-detected local remote", {
5672
6493
  path: config.autoDetectRemotePath
5673
6494
  });
5674
6495
  return config.autoDetectRemotePath;
@@ -5691,6 +6512,31 @@ var GitManager = class _GitManager {
5691
6512
  }
5692
6513
  return void 0;
5693
6514
  }
6515
+ /**
6516
+ * {@link resolveRemoteUrl} for a workspace about to be cloned, which needs a
6517
+ * remote: none resolving is the worker-not-ready error in a mode whose
6518
+ * remote the worker creates, and a configuration error otherwise.
6519
+ */
6520
+ static async resolveCloneRemoteUrl(options) {
6521
+ const remoteUrl = await _GitManager.resolveRemoteUrl(options);
6522
+ if (remoteUrl)
6523
+ return remoteUrl;
6524
+ const notReady = await _GitManager.remoteNotReadyError(options.mode);
6525
+ if (notReady)
6526
+ throw notReady;
6527
+ throw new Error("CanopyCMS: defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is required to initialize workspace");
6528
+ }
6529
+ /**
6530
+ * {@link cloneRepo} for a workspace, failing with one message that names the
6531
+ * workspace, the remote and the base branch, which git's own error mixes.
6532
+ */
6533
+ static async cloneWorkspace(remoteUrl, workspacePath, baseBranch, options) {
6534
+ try {
6535
+ await _GitManager.cloneRepo(remoteUrl, workspacePath, baseBranch, options);
6536
+ } catch (err) {
6537
+ throw new Error(`Failed to clone branch workspace at ${workspacePath} from ${remoteUrl} (base branch '${baseBranch}'): ${getErrorMessage(err)}`);
6538
+ }
6539
+ }
5694
6540
  /**
5695
6541
  * Check whether a git repository is already initialized at `workspacePath`.
5696
6542
  *
@@ -5730,21 +6576,24 @@ var GitManager = class _GitManager {
5730
6576
  if (!repoExists) {
5731
6577
  const gitPath = path17.join(options.workspacePath, ".git");
5732
6578
  try {
5733
- const stat = await fs16.stat(gitPath);
6579
+ const stat = await fs15.stat(gitPath);
5734
6580
  if (stat.isDirectory()) {
5735
- log5.debug("git", "Removing corrupt .git directory", {
6581
+ log7.debug("git", "Removing corrupt .git directory", {
5736
6582
  workspacePath: options.workspacePath
5737
6583
  });
5738
- await fs16.rm(gitPath, { recursive: true });
6584
+ await fs15.rm(gitPath, { recursive: true });
5739
6585
  }
5740
6586
  } catch (cleanupErr) {
5741
6587
  if (!isNotFoundError(cleanupErr))
5742
6588
  throw cleanupErr;
5743
6589
  }
5744
6590
  }
6591
+ const step = (name, run) => options.provisionLog ? options.provisionLog.step(name, run) : run();
6592
+ const identity = managedWorkspaceConfig(options.gitBotAuthorName, options.gitBotAuthorEmail);
5745
6593
  let justCloned = false;
6594
+ let resolvedRemoteUrl;
5746
6595
  if (!repoExists) {
5747
- const remoteUrl = await _GitManager.resolveRemoteUrl({
6596
+ const remoteUrl = await _GitManager.resolveCloneRemoteUrl({
5748
6597
  mode: options.mode,
5749
6598
  remoteUrl: options.remoteUrl,
5750
6599
  defaultRemoteUrl: options.defaultRemoteUrl,
@@ -5752,20 +6601,13 @@ var GitManager = class _GitManager {
5752
6601
  sourceRoot: options.sourceRoot,
5753
6602
  allowNetworkRemoteInProd: options.allowNetworkRemoteInProd
5754
6603
  });
5755
- if (!remoteUrl) {
5756
- throw new Error("CanopyCMS: defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is required to initialize workspace");
5757
- }
5758
- try {
5759
- await _GitManager.cloneRepo(remoteUrl, options.workspacePath, baseBranch);
5760
- } catch (err) {
5761
- throw new Error(`Failed to clone branch workspace at ${options.workspacePath} from ${remoteUrl} (base branch '${baseBranch}'): ${getErrorMessage(err)}`);
5762
- }
6604
+ await step("clone", () => _GitManager.cloneWorkspace(remoteUrl, options.workspacePath, baseBranch, {
6605
+ remoteName,
6606
+ noCheckout: options.branchType === "content",
6607
+ config: identity
6608
+ }));
5763
6609
  justCloned = true;
5764
- const freshGit = simpleGit2({ baseDir: options.workspacePath });
5765
- freshGit.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path17.dirname(options.workspacePath) }));
5766
- await freshGit.addConfig("canopycms.managed", "true");
5767
- await freshGit.addConfig("user.name", options.gitBotAuthorName);
5768
- await freshGit.addConfig("user.email", options.gitBotAuthorEmail);
6610
+ resolvedRemoteUrl = remoteUrl;
5769
6611
  }
5770
6612
  const git = new _GitManager({
5771
6613
  repoPath: options.workspacePath,
@@ -5773,32 +6615,38 @@ var GitManager = class _GitManager {
5773
6615
  remote: remoteName,
5774
6616
  skipIndexMarker: options.branchType === "orphan"
5775
6617
  });
5776
- await git.git.addConfig("canopycms.managed", "true");
5777
- await git.git.addConfig("user.name", options.gitBotAuthorName);
5778
- await git.git.addConfig("user.email", options.gitBotAuthorEmail);
5779
- log5.debug("git", "Marked workspace as CanopyCMS-managed", {
5780
- workspacePath: options.workspacePath
5781
- });
5782
6618
  if (!justCloned) {
5783
- const remoteUrl = await _GitManager.resolveRemoteUrl({
5784
- mode: options.mode,
5785
- remoteUrl: options.remoteUrl,
5786
- defaultRemoteUrl: options.defaultRemoteUrl,
5787
- baseBranch,
5788
- sourceRoot: options.sourceRoot,
5789
- allowNetworkRemoteInProd: options.allowNetworkRemoteInProd
6619
+ await step("config", async () => {
6620
+ await git.ensureLocalConfig(identity);
6621
+ const remoteUrl = await _GitManager.resolveRemoteUrl({
6622
+ mode: options.mode,
6623
+ remoteUrl: options.remoteUrl,
6624
+ defaultRemoteUrl: options.defaultRemoteUrl,
6625
+ baseBranch,
6626
+ sourceRoot: options.sourceRoot,
6627
+ allowNetworkRemoteInProd: options.allowNetworkRemoteInProd
6628
+ });
6629
+ if (remoteUrl) {
6630
+ await git.ensureRemote(remoteUrl);
6631
+ }
6632
+ resolvedRemoteUrl = remoteUrl;
5790
6633
  });
5791
- if (remoteUrl) {
5792
- await git.ensureRemote(remoteUrl);
5793
- }
5794
6634
  }
5795
6635
  if (options.branchType === "orphan") {
5796
- await git.createOrphanSettingsBranch(options.branchName, {});
5797
- await git.ensureGitExclude("*.lock");
6636
+ try {
6637
+ await step("checkout", () => git.createOrphanSettingsBranch(options.branchName, {}));
6638
+ } catch (err) {
6639
+ if (err instanceof SettingsRemoteUnreadableError && !resolvedRemoteUrl) {
6640
+ throw await _GitManager.remoteNotReadyError(options.mode) ?? err;
6641
+ }
6642
+ throw err;
6643
+ }
6644
+ await step("exclude", () => git.ensureGitExclude("*.lock"));
5798
6645
  } else {
5799
- await git.checkoutBranch(options.branchName);
5800
- if (options.gitExcludePattern) {
5801
- await git.ensureGitExclude(options.gitExcludePattern);
6646
+ await step("checkout", () => justCloned ? git.checkoutFreshClone(options.branchName) : git.checkoutBranch(options.branchName));
6647
+ const pattern = options.gitExcludePattern;
6648
+ if (pattern) {
6649
+ await step("exclude", () => git.ensureGitExclude(pattern));
5802
6650
  }
5803
6651
  }
5804
6652
  return git;
@@ -5840,6 +6688,21 @@ var GitManager = class _GitManager {
5840
6688
  await this.invalidateContentIndexes();
5841
6689
  }
5842
6690
  }
6691
+ /**
6692
+ * The one checkout of a `--no-checkout` clone, which has the base branch
6693
+ * and its remote-tracking ref and nothing else, so neither the branch
6694
+ * listing nor the fetch that {@link checkoutBranch} starts with is needed.
6695
+ * The missing index makes git populate the whole tree, even for the base
6696
+ * branch HEAD already names.
6697
+ */
6698
+ async checkoutFreshClone(branch) {
6699
+ const target = branch === this.baseBranch ? ["checkout", branch] : ["checkout", "-b", branch, `${this.remote}/${this.baseBranch}`];
6700
+ try {
6701
+ await this.git.raw([...PARALLEL_CHECKOUT_ARGS, ...target]);
6702
+ } finally {
6703
+ await this.invalidateContentIndexes();
6704
+ }
6705
+ }
5843
6706
  async checkoutBranchInner(branch) {
5844
6707
  const branches = await this.git.branch();
5845
6708
  if (branches.all.includes(branch)) {
@@ -5902,7 +6765,7 @@ var GitManager = class _GitManager {
5902
6765
  const branches = await this.git.branch();
5903
6766
  const currentBranch = branches.current;
5904
6767
  try {
5905
- await this.git.fetch(this.remote, currentBranch);
6768
+ await this.git.fetch(this.remote, `refs/heads/${currentBranch}`);
5906
6769
  } catch (err) {
5907
6770
  if (!isMissingRemoteRefFailure(getErrorMessage(err)))
5908
6771
  throw err;
@@ -5955,23 +6818,46 @@ var GitManager = class _GitManager {
5955
6818
  throw err;
5956
6819
  }
5957
6820
  }
6821
+ /** `--sparse` stages a named path outside a sparse clone's cone (branch-sparse.ts). */
5958
6822
  async add(files) {
5959
6823
  const fileArray = Array.isArray(files) ? files : [files];
5960
- await this.git.add(fileArray);
6824
+ await this.git.raw(["add", "--sparse", "--", ...fileArray]);
6825
+ }
6826
+ /**
6827
+ * Restrict the working tree to `dirs` in cone mode, which always keeps the root-level files.
6828
+ * Runs before a `--no-checkout` clone's one checkout, which invalidates the content caches.
6829
+ */
6830
+ async setSparseCone(dirs) {
6831
+ await this.git.raw(["sparse-checkout", "set", "--cone", "--", ...dirs]);
6832
+ }
6833
+ /** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
6834
+ async addAllExceptCanopyState() {
6835
+ await stageAllExceptCanopyState(this.git);
5961
6836
  }
5962
6837
  async commit(message) {
5963
6838
  await this.git.commit(message);
5964
6839
  }
5965
6840
  async push(branch) {
5966
- const target = branch ?? await this.git.revparse(["--abbrev-ref", "HEAD"]);
6841
+ const target = branch ?? await this.currentBranchName();
5967
6842
  await this.git.raw([
5968
6843
  "push",
5969
6844
  "--set-upstream",
5970
6845
  "--end-of-options",
5971
6846
  this.remote,
5972
- `${target}:${target}`
6847
+ `refs/heads/${target}:refs/heads/${target}`
5973
6848
  ]);
5974
6849
  }
6850
+ /**
6851
+ * The checked-out branch's plain name. `rev-parse --abbrev-ref HEAD` prints
6852
+ * `heads/<name>` when a same-named tag exists, which is not a branch name.
6853
+ */
6854
+ async currentBranchName() {
6855
+ const branches = await this.git.branch();
6856
+ if (branches.detached || !branches.current) {
6857
+ throw new Error(`CanopyCMS: no branch is checked out (detached HEAD) in ${this.repoPath}`);
6858
+ }
6859
+ return branches.current;
6860
+ }
5975
6861
  /**
5976
6862
  * Whether the local branch has commits the remote mirror doesn't -- i.e.
5977
6863
  * whether push() would move the remote ref forward.
@@ -5990,12 +6876,12 @@ var GitManager = class _GitManager {
5990
6876
  * ("couldn't find remote ref"); that counts as "ahead", not an error.
5991
6877
  */
5992
6878
  async hasUnpushedCommits(branch) {
5993
- const target = branch ?? await this.git.revparse(["--abbrev-ref", "--end-of-options", "HEAD"]);
5994
- const localSha = (await this.git.revparse(["--end-of-options", target])).trim();
6879
+ const target = branch ?? await this.git.revparse(["--verify", "--abbrev-ref", "--end-of-options", "HEAD"]);
6880
+ const localSha = (await this.git.revparse(["--verify", "--end-of-options", target])).trim();
5995
6881
  let fetchedTip;
5996
6882
  try {
5997
6883
  await this.git.raw(["fetch", "--end-of-options", this.remote, target]);
5998
- fetchedTip = (await this.git.revparse(["--end-of-options", "FETCH_HEAD"])).trim();
6884
+ fetchedTip = (await this.git.revparse(["--verify", "--end-of-options", "FETCH_HEAD"])).trim();
5999
6885
  } catch {
6000
6886
  return true;
6001
6887
  }
@@ -6004,6 +6890,25 @@ var GitManager = class _GitManager {
6004
6890
  const aheadCount = (await this.git.raw(["rev-list", "--count", `${fetchedTip}..${localSha}`])).trim();
6005
6891
  return aheadCount !== "0";
6006
6892
  }
6893
+ /**
6894
+ * Repo-relative paths that differ between HEAD and its merge base with the
6895
+ * base branch: everything the branch changes, across all of its commits.
6896
+ * Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
6897
+ * constraint.
6898
+ */
6899
+ async listChangedPathsSinceBase() {
6900
+ await this.git.raw(["fetch", "--end-of-options", this.remote, this.baseBranch]);
6901
+ const baseTip = (await this.git.revparse(["--verify", "--end-of-options", "FETCH_HEAD"])).trim();
6902
+ const output = await this.git.raw([
6903
+ "diff",
6904
+ "--name-only",
6905
+ "-z",
6906
+ "--no-renames",
6907
+ `${baseTip}...HEAD`,
6908
+ "--"
6909
+ ]);
6910
+ return output.split("\0").filter((p) => p.length > 0);
6911
+ }
6007
6912
  async ensureAuthor(author) {
6008
6913
  const config = await this.git.listConfig();
6009
6914
  const isManaged = config.all["canopycms.managed"] === "true";
@@ -6019,6 +6924,14 @@ var GitManager = class _GitManager {
6019
6924
  await this.git.addConfig("user.email", author.email);
6020
6925
  }
6021
6926
  }
6927
+ /** Writes each key into this repo's own config only where it differs. */
6928
+ async ensureLocalConfig(values) {
6929
+ const local = await this.git.listConfig("local");
6930
+ for (const [key, value] of Object.entries(values)) {
6931
+ if (local.all[key] !== value)
6932
+ await this.git.addConfig(key, value);
6933
+ }
6934
+ }
6022
6935
  async ensureRemote(remoteUrl) {
6023
6936
  const config = await this.git.listConfig();
6024
6937
  const isManaged = config.all["canopycms.managed"] === "true";
@@ -6057,10 +6970,24 @@ var GitManager = class _GitManager {
6057
6970
  await ensureGitExcludePattern(this.repoPath, pattern);
6058
6971
  }
6059
6972
  /**
6060
- * Create an orphan branch (no shared history) for settings, so
6061
- * deployment-specific settings never pollute content history. It holds only
6062
- * settings files committed by explicit path (permissions.json, groups.json at
6063
- * the workspace root).
6973
+ * Put the workspace on the settings branch: an orphan (no shared history),
6974
+ * so deployment-specific settings never pollute content history. It holds
6975
+ * only settings files committed by explicit path (permissions.json,
6976
+ * groups.json at the workspace root).
6977
+ *
6978
+ * The remote is the settings branch's durable copy, and the workspace clone
6979
+ * is `--single-branch` at the base branch, so the remote is asked first:
6980
+ * - no local branch: check out the remote's, or create an empty orphan only
6981
+ * when the remote has none. An unreadable remote throws
6982
+ * {@link SettingsRemoteUnreadableError} rather than guess "none".
6983
+ * - local branch present: a history unrelated to the remote's is repaired
6984
+ * onto the remote's when the local branch is still its empty initial
6985
+ * commit with a clean working tree, and otherwise throws
6986
+ * {@link SettingsBranchDivergedError}; local commits are never discarded.
6987
+ * An unreadable remote throws only for that empty branch, since one that
6988
+ * holds settings can serve them and the next save surfaces the remote error.
6989
+ * Either way a remote branch holding content is never adopted:
6990
+ * {@link SettingsBranchHasContentHistoryError}.
6064
6991
  */
6065
6992
  async createOrphanSettingsBranch(branchName, initialFiles) {
6066
6993
  try {
@@ -6070,56 +6997,709 @@ var GitManager = class _GitManager {
6070
6997
  }
6071
6998
  }
6072
6999
  async createOrphanSettingsBranchInner(branchName, initialFiles) {
6073
- log5.debug("git", "Creating orphan settings branch", { branchName });
7000
+ log7.debug("git", "Ensuring settings branch", { branchName });
6074
7001
  const branches = await this.git.branch();
6075
- if (branches.all.includes(branchName)) {
6076
- log5.debug("git", "Orphan branch already exists", { branchName });
7002
+ const localExists = branches.all.includes(branchName);
7003
+ if (localExists) {
6077
7004
  await this.git.checkout(branchName);
7005
+ }
7006
+ let remoteTip;
7007
+ try {
7008
+ remoteTip = await this.remoteBranchTip(branchName);
7009
+ } catch (err) {
7010
+ if (localExists && !await this.isEmptyInitialBranch(branchName)) {
7011
+ log7.debug("git", "Remote unreadable; serving the local settings branch", { branchName });
7012
+ return;
7013
+ }
7014
+ throw new SettingsRemoteUnreadableError(branchName, this.remote, err);
7015
+ }
7016
+ if (localExists) {
7017
+ await this.reconcileLocalSettingsBranch(branchName, remoteTip);
7018
+ return;
7019
+ }
7020
+ if (remoteTip) {
7021
+ const fetchedTip = await this.fetchBranchTip(branchName);
7022
+ await this.assertNotContentHistory(branchName, fetchedTip);
7023
+ await this.git.raw(["checkout", "-b", branchName, fetchedTip]);
7024
+ log7.debug("git", "Checked out the remote settings branch", { branchName });
6078
7025
  return;
6079
7026
  }
6080
7027
  await this.git.raw(["checkout", "--orphan", branchName]);
6081
- try {
6082
- await this.git.raw(["rm", "-rf", "."]);
6083
- } catch {
7028
+ await this.git.raw(["rm", "-r", "-f", "-q", "--sparse", "--ignore-unmatch", "--", "."]);
7029
+ const leftover = (await this.git.raw(["ls-files"])).trim();
7030
+ if (leftover) {
7031
+ throw new Error(`Settings branch '${branchName}' was not created: the base branch's files (${leftover.split("\n").slice(0, 3).join(", ")}) stayed in its index`);
6084
7032
  }
6085
7033
  for (const [filePath, content] of Object.entries(initialFiles)) {
6086
7034
  const absolutePath = path17.join(this.repoPath, filePath);
6087
- await fs16.mkdir(path17.dirname(absolutePath), { recursive: true });
6088
- await fs16.writeFile(absolutePath, content, "utf-8");
7035
+ await fs15.mkdir(path17.dirname(absolutePath), { recursive: true });
7036
+ await fs15.writeFile(absolutePath, content, "utf-8");
6089
7037
  await this.git.add(filePath);
6090
7038
  }
6091
7039
  await this.git.commit("Initialize settings branch", ["--allow-empty"]);
6092
- log5.debug("git", "Orphan settings branch created", { branchName });
7040
+ log7.debug("git", "Orphan settings branch created", { branchName });
7041
+ }
7042
+ /**
7043
+ * Compares the checked-out local settings branch with the remote's tip.
7044
+ * Related histories are left for the next settings pull to reconcile; only
7045
+ * an unrelated one is acted on here.
7046
+ */
7047
+ async reconcileLocalSettingsBranch(branchName, remoteTip) {
7048
+ if (!remoteTip)
7049
+ return;
7050
+ const localTip = (await this.git.revparse([`refs/heads/${branchName}`])).trim();
7051
+ if (localTip === remoteTip)
7052
+ return;
7053
+ const fetchedTip = await this.fetchBranchTip(branchName);
7054
+ const [localRoots, remoteRoots] = await Promise.all([
7055
+ this.rootCommits(localTip),
7056
+ this.rootCommits(fetchedTip)
7057
+ ]);
7058
+ if (localRoots.some((root) => remoteRoots.includes(root)))
7059
+ return;
7060
+ const localWork = [];
7061
+ if (!await this.isEmptyInitialBranch(branchName)) {
7062
+ localWork.push("commits beyond its empty initial commit");
7063
+ }
7064
+ const uncommitted = await this.getUncommittedFiles();
7065
+ if (uncommitted.length > 0) {
7066
+ localWork.push(`uncommitted changes to ${uncommitted.join(", ")}`);
7067
+ }
7068
+ if (localWork.length > 0) {
7069
+ throw new SettingsBranchDivergedError(branchName, this.repoPath, localWork.join(" and "));
7070
+ }
7071
+ await this.assertNotContentHistory(branchName, fetchedTip);
7072
+ await this.git.raw(["checkout", "-B", branchName, fetchedTip]);
7073
+ log7.debug("git", "Replaced an empty settings branch with the remote one", { branchName });
7074
+ }
7075
+ /**
7076
+ * The remote's tip for `branch`, or `undefined` when the remote has no such
7077
+ * branch. A remote that cannot be read throws: `ls-remote` exits 0 with no
7078
+ * output only for a reachable remote without the ref.
7079
+ */
7080
+ async remoteBranchTip(branch) {
7081
+ const ref = `refs/heads/${branch}`;
7082
+ const output = await this.git.raw(["ls-remote", "--heads", this.remote, ref]);
7083
+ for (const line of output.split("\n")) {
7084
+ const [sha, name] = line.trim().split(" ");
7085
+ if (sha && name === ref)
7086
+ return sha;
7087
+ }
7088
+ return void 0;
7089
+ }
7090
+ /**
7091
+ * Fetch `branch` from the remote and return the fetched commit, pinned as
7092
+ * pullBaseInner explains. The full ref: a bare name resolves to a
7093
+ * same-named tag first.
7094
+ */
7095
+ async fetchBranchTip(branch) {
7096
+ await this.git.fetch(this.remote, `refs/heads/${branch}`);
7097
+ return (await this.git.revparse(["FETCH_HEAD"])).trim();
7098
+ }
7099
+ /**
7100
+ * Refuse to adopt a remote settings branch that holds content: its tip has a file other than
7101
+ * the settings files, or its roots include the base branch's (a settings branch is an orphan).
7102
+ * Neither alone suffices: a base can have a root of its own, and a content tree can be pruned.
7103
+ * The base comes from the remote when the clone lacks it (it was cloned at an earlier base).
7104
+ */
7105
+ async assertNotContentHistory(branchName, tip) {
7106
+ const tree = await this.git.raw(["ls-tree", "-r", "--name-only", tip]);
7107
+ const strays = tree.split("\n").filter((file) => file && !SETTINGS_BRANCH_FILES.has(file));
7108
+ if (strays.length > 0) {
7109
+ const shown = strays.slice(0, 3).join(", ") + (strays.length > 3 ? ", \u2026" : "");
7110
+ throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it holds ${shown}`);
7111
+ }
7112
+ const [tipRoots, baseRoots] = await Promise.all([
7113
+ this.rootCommits(tip),
7114
+ this.baseBranchRev(branchName).then((rev) => this.rootCommits(rev))
7115
+ ]);
7116
+ if (tipRoots.some((root) => baseRoots.includes(root))) {
7117
+ throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it shares history with the base branch '${this.baseBranch}'`);
7118
+ }
7119
+ }
7120
+ async baseBranchRev(settingsBranch) {
7121
+ for (const ref of [
7122
+ `refs/heads/${this.baseBranch}`,
7123
+ `refs/remotes/${this.remote}/${this.baseBranch}`
7124
+ ]) {
7125
+ const sha = await this.git.raw(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).catch(() => "");
7126
+ if (sha.trim())
7127
+ return sha.trim();
7128
+ }
7129
+ try {
7130
+ return await this.fetchBranchTip(this.baseBranch);
7131
+ } catch (err) {
7132
+ throw new Error(`CanopyCMS: could not read base branch '${this.baseBranch}' from remote '${this.remote}', so settings branch '${settingsBranch}' was not checked out: ${getErrorMessage(err)}`);
7133
+ }
7134
+ }
7135
+ async rootCommits(rev) {
7136
+ const output = await this.git.raw(["rev-list", "--max-parents=0", rev]);
7137
+ return output.split("\n").filter(Boolean);
7138
+ }
7139
+ /**
7140
+ * Whether `branch` is a single parentless commit with an empty tree, as
7141
+ * orphan creation leaves it. Full refs throughout: a bare name resolves to a
7142
+ * same-named tag first, and clones fetch tags.
7143
+ */
7144
+ async isEmptyInitialBranch(branch) {
7145
+ const ref = `refs/heads/${branch}`;
7146
+ const commits = await this.git.raw(["rev-list", "--max-count=2", ref]);
7147
+ if (commits.split("\n").filter(Boolean).length !== 1)
7148
+ return false;
7149
+ const tree = await this.git.raw(["ls-tree", ref]);
7150
+ return tree.trim() === "";
7151
+ }
7152
+ };
7153
+
7154
+ // dist/branch-provisioning.js
7155
+ init_logger();
7156
+ var PUBLISH_LOCK_WAIT_MS = 8e3;
7157
+ var CREATE_RESIDUE_MIN_QUIET_MS = 6e4;
7158
+ var STAGING_MAX_AGE_MS = 20 * 6e4;
7159
+ var REPAIR_MAX_AGE_MS = 20 * 6e4;
7160
+ var DELETING_MAX_AGE_MS = 10 * 6e4;
7161
+ var SETTINGS_TRASH_MAX_AGE_MS = 30 * 24 * 60 * 6e4;
7162
+ var BranchProvisioningBusyError = class extends Error {
7163
+ constructor(dirName, message = `Branch '${dirName}' is still being set up. Try again in a minute.`) {
7164
+ super(message);
7165
+ this.dirName = dirName;
7166
+ this.name = "BranchProvisioningBusyError";
7167
+ }
7168
+ };
7169
+ var BranchDirOccupiedError = class extends Error {
7170
+ constructor(dirName) {
7171
+ super(`A directory named '${dirName}' already exists in the branch workspace and holds a git repository this deployment did not create. It was left untouched; an admin can purge it from System Health, or choose another branch name.`);
7172
+ this.dirName = dirName;
7173
+ this.name = "BranchDirOccupiedError";
7174
+ }
7175
+ };
7176
+ function formatDirStamp(date) {
7177
+ return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
7178
+ }
7179
+ function shortDirName(dirName) {
7180
+ const digest = createHash("sha1").update(dirName).digest("hex").slice(0, 10);
7181
+ return `${dirName.slice(0, 40)}-${digest}`;
7182
+ }
7183
+ function stampedName(prefix, dirName, now) {
7184
+ return `${prefix}${shortDirName(dirName)}-${randomBytes(3).toString("hex")}-${formatDirStamp(now)}`;
7185
+ }
7186
+ var STAGING_PREFIX = ".prov-";
7187
+ var REPAIR_PREFIX = ".repair-";
7188
+ var TRASH_PREFIX = ".trash-";
7189
+ var stagingDirName = (dirName, now = /* @__PURE__ */ new Date()) => stampedName(STAGING_PREFIX, dirName, now);
7190
+ var NO_GIT = { hasGitDir: false, configLock: false, hasIndex: false };
7191
+ async function listDir(dirPath) {
7192
+ try {
7193
+ return await fs16.readdir(dirPath);
7194
+ } catch (err) {
7195
+ if (isNodeError(err) && (err.code === "ENOENT" || err.code === "ENOTDIR"))
7196
+ return null;
7197
+ throw err;
7198
+ }
7199
+ }
7200
+ function gitConfigRemoteUrls(text) {
7201
+ const urls = [];
7202
+ let inRemote = false;
7203
+ for (const raw of text.split("\n")) {
7204
+ const line = raw.trim();
7205
+ if (line.startsWith("[")) {
7206
+ inRemote = /^\[\s*remote\s+"[^"]*"\s*\]/i.test(line);
7207
+ continue;
7208
+ }
7209
+ const match = inRemote ? /^url\s*=\s*(.*)$/i.exec(line) : null;
7210
+ if (match)
7211
+ urls.push(match[1].trim().replace(/^"(.*)"$/, "$1"));
7212
+ }
7213
+ return urls;
7214
+ }
7215
+ function sameRemote(url, expected) {
7216
+ if (isNetworkRemoteUrl(url) || isNetworkRemoteUrl(expected))
7217
+ return url === expected;
7218
+ const local = (value) => path18.resolve(value.replace(/^file:\/\//i, ""));
7219
+ return local(url) === local(expected);
7220
+ }
7221
+ async function classifyFinalDir(dirPath, expectedRemoteUrl) {
7222
+ let stat;
7223
+ try {
7224
+ stat = await fs16.lstat(dirPath);
7225
+ } catch (err) {
7226
+ if (isNotFoundError(err))
7227
+ return { kind: "vacant" };
7228
+ throw err;
7229
+ }
7230
+ if (!stat.isDirectory())
7231
+ return { kind: "residue", signature: NO_GIT };
7232
+ const metaEntries = await listDir(path18.join(dirPath, BRANCH_META_DIR)) ?? [];
7233
+ if (metaEntries.includes(BRANCH_META_FILE)) {
7234
+ try {
7235
+ return await readBranchMetadataFile(dirPath) ? { kind: "live" } : { kind: "protected" };
7236
+ } catch (err) {
7237
+ if (err instanceof BranchMetadataCorruptError)
7238
+ return { kind: "corrupt" };
7239
+ throw err;
7240
+ }
7241
+ }
7242
+ if (metaEntries.some((name) => name.startsWith(`${BRANCH_META_FILE}.corrupt-`))) {
7243
+ return { kind: "protected" };
7244
+ }
7245
+ const gitPath = path18.join(dirPath, ".git");
7246
+ let gitStat;
7247
+ try {
7248
+ gitStat = await fs16.lstat(gitPath);
7249
+ } catch (err) {
7250
+ if (isNotFoundError(err))
7251
+ return { kind: "residue", signature: NO_GIT };
7252
+ throw err;
7253
+ }
7254
+ if (!gitStat.isDirectory())
7255
+ return { kind: "foreign" };
7256
+ const gitEntries = await listDir(gitPath) ?? [];
7257
+ const signature = {
7258
+ hasGitDir: true,
7259
+ configLock: gitEntries.includes("config.lock"),
7260
+ hasIndex: gitEntries.includes("index")
7261
+ };
7262
+ let config;
7263
+ try {
7264
+ config = await fs16.readFile(path18.join(gitPath, "config"), "utf8");
7265
+ } catch {
7266
+ return { kind: "residue", signature };
7267
+ }
7268
+ return gitConfigRemoteUrls(config).some((url) => sameRemote(url, expectedRemoteUrl)) ? { kind: "residue", signature } : { kind: "foreign" };
7269
+ }
7270
+ async function quietAgeMs(dirPath, now = Date.now()) {
7271
+ const mtimes = await Promise.all(["", ".git", BRANCH_META_DIR].map((sub) => fs16.lstat(path18.join(dirPath, sub)).then((stat) => stat.mtimeMs, () => 0)));
7272
+ return now - Math.max(...mtimes);
7273
+ }
7274
+ async function quarantineResidueAt(baseRoot, dirName, options) {
7275
+ const finalPath = path18.join(baseRoot, dirName);
7276
+ let inode;
7277
+ try {
7278
+ inode = (await fs16.lstat(finalPath)).ino;
7279
+ } catch (err) {
7280
+ if (isNotFoundError(err))
7281
+ return { kind: "vacant" };
7282
+ throw err;
7283
+ }
7284
+ const candidateName = stampedName(REPAIR_PREFIX, dirName, /* @__PURE__ */ new Date());
7285
+ const candidate = path18.join(baseRoot, candidateName);
7286
+ try {
7287
+ await fs16.rename(finalPath, candidate);
7288
+ } catch (err) {
7289
+ if (isNotFoundError(err))
7290
+ return { kind: "vacant" };
7291
+ throw err;
7292
+ }
7293
+ const moveBack = async (why) => {
7294
+ try {
7295
+ await fs16.rename(candidate, finalPath);
7296
+ } catch (err) {
7297
+ canopyLogError(`[canopy] Could not move '${dirName}' back from ${candidateName} after ${why}: ${getErrorMessage(err)}. The worker's sweep restores or trashes it.`);
7298
+ }
7299
+ };
7300
+ const { state, quiet, reason } = await judgeQuarantineCandidate(candidate, inode, options).catch(async (err) => {
7301
+ await moveBack(`failing to judge it (${getErrorMessage(err)})`);
7302
+ throw err;
7303
+ });
7304
+ if (reason === void 0 && state.kind === "residue") {
7305
+ const trashName = stampedName(TRASH_PREFIX, dirName, /* @__PURE__ */ new Date());
7306
+ await fs16.rename(candidate, path18.join(baseRoot, trashName));
7307
+ const { hasGitDir, configLock, hasIndex } = state.signature;
7308
+ canopyLogWarn(`[canopy] Quarantined unfinished branch directory '${dirName}' as ${trashName} (git=${hasGitDir} config.lock=${configLock} index=${hasIndex} quiet=${Math.round(quiet / 1e3)}s)`);
7309
+ return { kind: "quarantined", trashName };
7310
+ }
7311
+ await moveBack(`declining to quarantine it (${reason})`);
7312
+ return { kind: "kept", reason: reason ?? "replaced" };
7313
+ }
7314
+ async function judgeQuarantineCandidate(candidate, inode, options) {
7315
+ const state = await classifyFinalDir(candidate, options.expectedRemoteUrl);
7316
+ const moved = await fs16.lstat(candidate).catch(() => null);
7317
+ const quiet = await quietAgeMs(candidate, options.now);
7318
+ let reason;
7319
+ if (!moved || moved.ino !== inode)
7320
+ reason = "replaced";
7321
+ else if (state.kind === "vacant")
7322
+ reason = "replaced";
7323
+ else if (state.kind !== "residue")
7324
+ reason = state.kind;
7325
+ else if (quiet < options.minQuietMs)
7326
+ reason = "too-young";
7327
+ return { state, quiet, reason };
7328
+ }
7329
+ async function removeLeftoverDir(dirPath) {
7330
+ try {
7331
+ await fs16.rm(dirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
7332
+ } catch (err) {
7333
+ canopyLogWarn(`[canopy] Could not remove ${dirPath}; the worker sweeps it: ${getErrorMessage(err)}`);
7334
+ }
7335
+ }
7336
+ async function hasBranchMetadata(branchRoot) {
7337
+ return fs16.access(path18.join(branchRoot, BRANCH_META_DIR, BRANCH_META_FILE)).then(() => true, () => false);
7338
+ }
7339
+ async function stageBranchWorkspace(options) {
7340
+ const { provisionLog: log9 } = options;
7341
+ const stagingPath = path18.join(options.baseRoot, stagingDirName(options.dirName));
7342
+ try {
7343
+ await log9.step("clone", () => GitManager.cloneWorkspace(options.remoteUrl, stagingPath, options.baseBranch, {
7344
+ ...options.clone,
7345
+ noCheckout: true
7346
+ }));
7347
+ const git = new GitManager({
7348
+ repoPath: stagingPath,
7349
+ baseBranch: options.baseBranch,
7350
+ remote: options.clone.remoteName
7351
+ });
7352
+ const cone = options.sparseCone;
7353
+ if (cone)
7354
+ await log9.step("sparse", () => git.setSparseCone(cone));
7355
+ if (await hasBranchMetadata(path18.join(options.baseRoot, options.dirName))) {
7356
+ await removeLeftoverDir(stagingPath);
7357
+ return { kind: "exists" };
7358
+ }
7359
+ await log9.step("checkout", () => git.checkoutFreshClone(options.branchName));
7360
+ await log9.step("exclude", () => git.ensureGitExclude(options.gitExcludePattern));
7361
+ const written = await log9.step("metadata", () => writeOccJsonFile(path18.join(stagingPath, BRANCH_META_DIR, BRANCH_META_FILE), { ...options.metadata }, { expectedVersion: null, trailingNewline: true }));
7362
+ return {
7363
+ kind: "staged",
7364
+ stagingPath,
7365
+ meta: { ...options.metadata, version: written.version, writeId: written.writeId }
7366
+ };
7367
+ } catch (err) {
7368
+ await removeLeftoverDir(stagingPath);
7369
+ throw err;
7370
+ }
7371
+ }
7372
+ async function lockBranchName(baseRoot, dirName) {
7373
+ try {
7374
+ return await acquireProvisioningLockWithin(baseRoot, branchProvisioningLockName(dirName), PUBLISH_LOCK_WAIT_MS);
7375
+ } catch (err) {
7376
+ if (isNodeError(err) && err.code === "ELOCKED")
7377
+ throw new BranchProvisioningBusyError(dirName);
7378
+ throw err;
7379
+ }
7380
+ }
7381
+ async function inspectBlocking(finalPath) {
7382
+ const entries = await listDir(path18.join(finalPath, BRANCH_META_DIR)) ?? [];
7383
+ if (entries.includes(BRANCH_META_FILE))
7384
+ return "live";
7385
+ if (entries.some((name) => name.startsWith(`${BRANCH_META_FILE}.corrupt-`)))
7386
+ return "protected";
7387
+ return "residue";
7388
+ }
7389
+ var RENAME_BLOCKED = /* @__PURE__ */ new Set(["EEXIST", "ENOTEMPTY", "ENOTDIR"]);
7390
+ async function publishStaging(options) {
7391
+ const { baseRoot, dirName, stagingPath } = options;
7392
+ const finalPath = path18.join(baseRoot, dirName);
7393
+ const release = await lockBranchName(baseRoot, dirName);
7394
+ try {
7395
+ for (let attempt = 0; attempt < 2; attempt++) {
7396
+ try {
7397
+ await fs16.rename(stagingPath, finalPath);
7398
+ return "published";
7399
+ } catch (err) {
7400
+ if (!isNodeError(err))
7401
+ throw err;
7402
+ if (err.code === "ENOENT") {
7403
+ const meta = await readBranchMetadataFile(finalPath).catch(() => null);
7404
+ if (meta?.writeId !== void 0 && meta.writeId === options.writeId)
7405
+ return "published";
7406
+ throw err;
7407
+ }
7408
+ if (!RENAME_BLOCKED.has(err.code ?? ""))
7409
+ throw err;
7410
+ }
7411
+ const blocking = await (options.inspectBlocked ?? inspectBlocking)(finalPath);
7412
+ if (blocking === "live")
7413
+ return "exists";
7414
+ if (blocking === "protected")
7415
+ throw new BranchProvisioningBusyError(dirName);
7416
+ const result = await quarantineResidueAt(baseRoot, dirName, {
7417
+ minQuietMs: CREATE_RESIDUE_MIN_QUIET_MS,
7418
+ expectedRemoteUrl: options.expectedRemoteUrl
7419
+ });
7420
+ if (result.kind !== "kept")
7421
+ continue;
7422
+ if (result.reason === "live")
7423
+ return "exists";
7424
+ if (result.reason === "corrupt") {
7425
+ await readBranchMetadataFile(finalPath);
7426
+ return "exists";
7427
+ }
7428
+ if (result.reason === "foreign")
7429
+ throw new BranchDirOccupiedError(dirName);
7430
+ throw new BranchProvisioningBusyError(dirName);
7431
+ }
7432
+ throw new BranchProvisioningBusyError(dirName);
7433
+ } finally {
7434
+ await release().catch((err) => {
7435
+ canopyLogWarn(`[canopy] Failed to release the provisioning lock for ${dirName}:`, err);
7436
+ });
7437
+ }
7438
+ }
7439
+
7440
+ // dist/branch-sparse.js
7441
+ import fs17 from "node:fs/promises";
7442
+ import path19 from "node:path";
7443
+ init_operating_mode();
7444
+ init_logger();
7445
+ function sparseConeFor(contentRoot) {
7446
+ const root = (contentRoot ?? "content").split("/").filter((segment) => segment && segment !== ".").join("/");
7447
+ return root ? [.../* @__PURE__ */ new Set([CANOPY_META_DIR, root])].sort() : null;
7448
+ }
7449
+ function sameCone(a, b) {
7450
+ if (a === null || b === null)
7451
+ return a === b;
7452
+ const [left, right] = [[...a].sort(), [...b].sort()];
7453
+ return left.length === right.length && left.every((dir, i) => dir === right[i]);
7454
+ }
7455
+ var SPARSE_CONE_RECORD = ".sparse-cone.json";
7456
+ function isConeDir(dir) {
7457
+ return typeof dir === "string" && dir.length > 0 && !path19.isAbsolute(dir) && !dir.split("/").some((segment) => segment === "" || segment === "." || segment === "..");
7458
+ }
7459
+ async function readRecordedSparseCone(contentBranchesRoot) {
7460
+ let raw;
7461
+ try {
7462
+ raw = await fs17.readFile(path19.join(contentBranchesRoot, SPARSE_CONE_RECORD), "utf8");
7463
+ } catch (err) {
7464
+ if (isNodeError(err) && err.code === "ENOENT")
7465
+ return void 0;
7466
+ throw err;
7467
+ }
7468
+ let parsed;
7469
+ try {
7470
+ parsed = JSON.parse(raw);
7471
+ } catch {
7472
+ return void 0;
7473
+ }
7474
+ if (typeof parsed !== "object" || parsed === null || !("cone" in parsed))
7475
+ return void 0;
7476
+ const { cone } = parsed;
7477
+ if (cone === null)
7478
+ return { cone: null };
7479
+ return Array.isArray(cone) && cone.length > 0 && cone.every(isConeDir) ? { cone: cone.filter(isConeDir) } : void 0;
7480
+ }
7481
+ async function recordSparseCone(contentBranchesRoot, cone) {
7482
+ const current = await readRecordedSparseCone(contentBranchesRoot);
7483
+ if (current && sameCone(current.cone, cone))
7484
+ return;
7485
+ const target = path19.join(contentBranchesRoot, SPARSE_CONE_RECORD);
7486
+ const temp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
7487
+ try {
7488
+ await fs17.writeFile(temp, `${JSON.stringify({ cone })}
7489
+ `, "utf8");
7490
+ } catch (err) {
7491
+ if (isNodeError(err) && err.code === "ENOENT")
7492
+ return;
7493
+ throw err;
7494
+ }
7495
+ try {
7496
+ await fs17.rename(temp, target);
7497
+ } catch (err) {
7498
+ await fs17.unlink(temp).catch(() => {
7499
+ });
7500
+ throw err;
7501
+ }
7502
+ }
7503
+
7504
+ // dist/branch-workspace.js
7505
+ init_operating_mode();
7506
+ init_logger();
7507
+
7508
+ // dist/utils/provision-log.js
7509
+ init_logger();
7510
+ import { randomBytes as randomBytes2 } from "node:crypto";
7511
+ var defaultSink = (line) => canopyLog(line);
7512
+ var sink = defaultSink;
7513
+ var ProvisionLog = class {
7514
+ constructor(dir) {
7515
+ this.id = randomBytes2(3).toString("hex");
7516
+ this.startedAt = Date.now();
7517
+ this.durations = [];
7518
+ this.prefix = `[canopy] provision id=${this.id} dir=${dir}`;
7519
+ }
7520
+ async step(name, run) {
7521
+ sink(`${this.prefix} step=${name} start`);
7522
+ const stepStartedAt = Date.now();
7523
+ let state = "failed";
7524
+ try {
7525
+ const result = await run();
7526
+ state = "done";
7527
+ return result;
7528
+ } finally {
7529
+ const ms = Date.now() - stepStartedAt;
7530
+ this.durations.push(`${name}=${ms}`);
7531
+ sink(`${this.prefix} step=${name} ${state} ms=${ms}`);
7532
+ }
7533
+ }
7534
+ finish(outcome) {
7535
+ const total = Date.now() - this.startedAt;
7536
+ sink(`${this.prefix} outcome=${outcome} total=${total} ${this.durations.join(" ")}`.trim());
6093
7537
  }
6094
7538
  };
6095
7539
 
6096
7540
  // dist/branch-workspace.js
6097
- var log6 = createDebugLogger({ prefix: "BranchWorkspace" });
6098
- var workspaceInitLocks = /* @__PURE__ */ new Map();
7541
+ var testHooks = {};
7542
+ var provisionsInFlight = /* @__PURE__ */ new Map();
6099
7543
  var BranchWorkspaceManager = class {
6100
7544
  constructor(config) {
6101
7545
  this.config = config;
6102
7546
  }
6103
- async ensureGitWorkspace(options) {
6104
- return log6.timed("workspace", "ensureGitWorkspace", async () => {
6105
- const existingLock = workspaceInitLocks.get(options.branchRoot);
6106
- if (existingLock) {
6107
- await existingLock;
6108
- return;
7547
+ /** The branch's context, provisioning it first when it does not exist. */
7548
+ async openOrCreateBranch(options) {
7549
+ return (await this.provisionBranch(options)).context;
7550
+ }
7551
+ /**
7552
+ * Provision a branch workspace, or report the one already there.
7553
+ *
7554
+ * @throws BranchProvisioningBusyError when another process is mid-way through this branch, or
7555
+ * what is at its path is too fresh to judge (retriable)
7556
+ * @throws BranchDirOccupiedError in prod, when its path holds a repository this deployment did
7557
+ * not create
7558
+ * @throws BranchMetadataCorruptError when its branch.json does not parse
7559
+ */
7560
+ async provisionBranch(options) {
7561
+ if (isSettingsBranchName(options.branchName, operatingStrategy(options.mode).getSettingsBranchName(this.config))) {
7562
+ throw new BranchPathError("Settings branches are not content branches");
7563
+ }
7564
+ const { branchRoot, baseRoot, branchName: safeName } = resolveBranchPath(options);
7565
+ const paths = {
7566
+ branchRoot,
7567
+ baseRoot,
7568
+ dirName: path20.basename(branchRoot),
7569
+ safeName
7570
+ };
7571
+ const existing = await readBranchMetadataFile(branchRoot);
7572
+ if (existing) {
7573
+ return { kind: "exists", context: { branch: existing.branch, branchRoot, baseRoot } };
7574
+ }
7575
+ const inFlight = provisionsInFlight.get(branchRoot);
7576
+ if (inFlight)
7577
+ return { kind: "exists", context: (await inFlight).context };
7578
+ const run = this.provisionUnshared(options, paths);
7579
+ provisionsInFlight.set(branchRoot, run);
7580
+ try {
7581
+ return await run;
7582
+ } finally {
7583
+ provisionsInFlight.delete(branchRoot);
7584
+ }
7585
+ }
7586
+ async provisionUnshared(options, paths) {
7587
+ const { branchRoot, baseRoot, dirName } = paths;
7588
+ await fs18.mkdir(baseRoot, { recursive: true });
7589
+ const baseBranch = await resolveBaseBranch({
7590
+ defaultBaseBranch: this.config.defaultBaseBranch,
7591
+ mode: options.mode,
7592
+ detectFrom: this.config.sourceRoot ? path20.resolve(process.cwd(), this.config.sourceRoot) : void 0
7593
+ });
7594
+ const remoteUrl = await GitManager.resolveCloneRemoteUrl({
7595
+ mode: options.mode,
7596
+ remoteUrl: options.remoteUrl,
7597
+ defaultRemoteUrl: this.config.defaultRemoteUrl,
7598
+ baseBranch,
7599
+ sourceRoot: this.config.sourceRoot,
7600
+ allowNetworkRemoteInProd: this.config.allowNetworkRemoteInProd
7601
+ });
7602
+ const metadata = buildInitialBranchMetadata({
7603
+ branch: {
7604
+ name: paths.safeName,
7605
+ title: options.title,
7606
+ description: options.description,
7607
+ access: options.access,
7608
+ createdBy: options.createdBy,
7609
+ baseBranch
6109
7610
  }
6110
- const lockPromise = (async () => {
6111
- let releaseLock;
6112
- try {
6113
- log6.debug("workspace", "Ensuring git workspace", {
6114
- branchName: options.branchName,
6115
- mode: options.mode
6116
- });
6117
- releaseLock = await acquireProvisioningLock(path18.dirname(options.branchRoot), `.${path18.basename(options.branchRoot)}.init.lock`);
7611
+ });
7612
+ const state = await classifyFinalDir(branchRoot, remoteUrl);
7613
+ if (state.kind === "live" || state.kind === "corrupt")
7614
+ return this.existing(paths);
7615
+ if (state.kind === "protected")
7616
+ throw new BranchProvisioningBusyError(dirName);
7617
+ if (state.kind === "foreign") {
7618
+ if (options.mode !== "dev")
7619
+ throw new BranchDirOccupiedError(dirName);
7620
+ return this.adoptInPlace(options, paths, baseBranch, metadata);
7621
+ }
7622
+ if (state.kind === "residue" && await this.recentlyActive(paths)) {
7623
+ throw new BranchProvisioningBusyError(dirName);
7624
+ }
7625
+ const sparseCone = sparseConeFor(this.config.contentRoot);
7626
+ await recordSparseCone(baseRoot, sparseCone).catch((err) => {
7627
+ canopyLogWarn(`[canopy] Could not record the sparse-checkout cone: ${getErrorMessage(err)}`);
7628
+ });
7629
+ const provisionLog = new ProvisionLog(dirName);
7630
+ let outcome;
7631
+ try {
7632
+ outcome = await this.stageAndPublish(paths, {
7633
+ provisionLog,
7634
+ baseBranch,
7635
+ remoteUrl,
7636
+ metadata,
7637
+ mode: options.mode,
7638
+ sparseCone
7639
+ });
7640
+ } catch (err) {
7641
+ provisionLog.finish("error");
7642
+ throw err;
7643
+ }
7644
+ provisionLog.finish(outcome.kind === "created" ? "ok" : "exists");
7645
+ return outcome;
7646
+ }
7647
+ async stageAndPublish(paths, run) {
7648
+ const { branchRoot, baseRoot, dirName } = paths;
7649
+ const staged = await stageBranchWorkspace({
7650
+ baseRoot,
7651
+ dirName,
7652
+ branchName: paths.safeName,
7653
+ baseBranch: run.baseBranch,
7654
+ remoteUrl: run.remoteUrl,
7655
+ clone: {
7656
+ remoteName: this.config.defaultRemoteName,
7657
+ config: managedWorkspaceConfig(this.config.gitBotAuthorName, this.config.gitBotAuthorEmail)
7658
+ },
7659
+ gitExcludePattern: operatingStrategy(run.mode).getGitExcludePattern(),
7660
+ metadata: run.metadata,
7661
+ provisionLog: run.provisionLog,
7662
+ sparseCone: run.sparseCone
7663
+ });
7664
+ if (staged.kind === "exists")
7665
+ return this.existing(paths);
7666
+ let published;
7667
+ try {
7668
+ await testHooks.beforePublish?.({ stagingPath: staged.stagingPath, finalPath: branchRoot });
7669
+ published = await run.provisionLog.step("publish", () => publishStaging({
7670
+ baseRoot,
7671
+ dirName,
7672
+ stagingPath: staged.stagingPath,
7673
+ writeId: staged.meta.writeId,
7674
+ expectedRemoteUrl: run.remoteUrl,
7675
+ inspectBlocked: testHooks.inspectBlocked
7676
+ }));
7677
+ } finally {
7678
+ if (published !== "published")
7679
+ await removeLeftoverDir(staged.stagingPath);
7680
+ }
7681
+ if (published === "exists")
7682
+ return this.existing(paths);
7683
+ await run.provisionLog.step("register", () => register(paths));
7684
+ return { kind: "created", context: { branch: staged.meta.branch, branchRoot, baseRoot } };
7685
+ }
7686
+ /**
7687
+ * The one exception to [PROV-1]: a dev workspace that already holds real work is kept where it
7688
+ * is, given branch.json under the branch's lock.
7689
+ */
7690
+ async adoptInPlace(options, paths, baseBranch, metadata) {
7691
+ const { branchRoot, baseRoot, dirName } = paths;
7692
+ const provisionLog = new ProvisionLog(dirName);
7693
+ let created = false;
7694
+ try {
7695
+ const release = await provisionLog.step("lock", () => lockBranchName(baseRoot, dirName));
7696
+ try {
7697
+ if (!await readBranchMetadataFile(branchRoot)) {
6118
7698
  await GitManager.initializeWorkspace({
6119
- workspacePath: options.branchRoot,
6120
- branchName: options.branchName,
7699
+ workspacePath: branchRoot,
7700
+ branchName: paths.safeName,
6121
7701
  mode: options.mode,
6122
- baseBranch: options.baseBranch,
7702
+ baseBranch,
6123
7703
  sourceRoot: this.config.sourceRoot,
6124
7704
  defaultRemoteUrl: this.config.defaultRemoteUrl,
6125
7705
  remoteUrl: options.remoteUrl,
@@ -6128,60 +7708,55 @@ var BranchWorkspaceManager = class {
6128
7708
  branchType: "content",
6129
7709
  gitBotAuthorName: this.config.gitBotAuthorName,
6130
7710
  gitBotAuthorEmail: this.config.gitBotAuthorEmail,
6131
- gitExcludePattern: operatingStrategy(options.mode).getGitExcludePattern()
7711
+ gitExcludePattern: operatingStrategy(options.mode).getGitExcludePattern(),
7712
+ provisionLog
6132
7713
  });
6133
- } finally {
6134
- if (releaseLock) {
6135
- try {
6136
- await releaseLock();
6137
- } catch (err) {
6138
- log6.debug("workspace", "Failed to release workspace-init lock", { err });
6139
- }
6140
- }
6141
- workspaceInitLocks.delete(options.branchRoot);
7714
+ created = await provisionLog.step("metadata", () => writeOccJsonFile(path20.join(branchRoot, BRANCH_META_DIR, BRANCH_META_FILE), { ...metadata }, { expectedVersion: null, trailingNewline: true }).then(() => true, (err) => {
7715
+ if (err instanceof OccWriteConflictError)
7716
+ return false;
7717
+ throw err;
7718
+ }));
6142
7719
  }
6143
- })();
6144
- workspaceInitLocks.set(options.branchRoot, lockPromise);
6145
- await lockPromise;
6146
- });
6147
- }
6148
- async openOrCreateBranch(options) {
6149
- const { branchName, mode, basePathOverride, title, description, access, createdBy, remoteUrl } = options;
6150
- const { branchRoot, baseRoot, branchName: safeName } = await ensureBranchRoot({
6151
- mode,
6152
- branchName,
6153
- basePathOverride
6154
- });
6155
- const baseBranch = await resolveBaseBranch({
6156
- defaultBaseBranch: this.config.defaultBaseBranch,
6157
- mode,
6158
- detectFrom: this.config.sourceRoot ? path18.resolve(process.cwd(), this.config.sourceRoot) : void 0
6159
- });
6160
- await this.ensureGitWorkspace({
6161
- branchRoot,
6162
- branchName: safeName,
6163
- mode,
6164
- baseBranch,
6165
- remoteUrl
6166
- });
6167
- const metadata = getBranchMetadataFileManager(branchRoot, baseRoot);
6168
- const meta = await metadata.save({
6169
- branch: {
6170
- name: safeName,
6171
- title,
6172
- description,
6173
- access,
6174
- createdBy,
6175
- baseBranch
7720
+ } finally {
7721
+ await release().catch(() => {
7722
+ });
6176
7723
  }
6177
- });
6178
- return {
6179
- branch: meta.branch,
6180
- branchRoot,
6181
- baseRoot
6182
- };
7724
+ if (created)
7725
+ await provisionLog.step("register", () => register(paths));
7726
+ } catch (err) {
7727
+ provisionLog.finish("error");
7728
+ throw err;
7729
+ }
7730
+ provisionLog.finish(created ? "ok" : "exists");
7731
+ if (!created)
7732
+ return this.existing(paths);
7733
+ const meta = await readBranchMetadataFile(branchRoot);
7734
+ if (!meta)
7735
+ throw new BranchProvisioningBusyError(dirName);
7736
+ return { kind: "created", context: { branch: meta.branch, branchRoot, baseRoot } };
7737
+ }
7738
+ /** Whether residue at the branch path may still belong to a live process. */
7739
+ async recentlyActive({ branchRoot, baseRoot, dirName }) {
7740
+ const lockAgeMs = await fs18.stat(path20.join(baseRoot, branchProvisioningLockName(dirName))).then((stat) => Date.now() - stat.mtimeMs, () => Infinity);
7741
+ if (lockAgeMs < PROVISIONING_LOCK_STALE_MS)
7742
+ return true;
7743
+ return await quietAgeMs(branchRoot) < CREATE_RESIDUE_MIN_QUIET_MS;
7744
+ }
7745
+ async existing({ branchRoot, baseRoot, dirName }) {
7746
+ const meta = await readBranchMetadataFile(branchRoot);
7747
+ if (!meta)
7748
+ throw new BranchProvisioningBusyError(dirName);
7749
+ return { kind: "exists", context: { branch: meta.branch, branchRoot, baseRoot } };
6183
7750
  }
6184
7751
  };
7752
+ async function register({ branchRoot, baseRoot }) {
7753
+ try {
7754
+ await invalidateBranchContentCaches(branchRoot);
7755
+ await new BranchRegistry(baseRoot).invalidate();
7756
+ } catch (err) {
7757
+ canopyLogWarn(`[canopy] Registering new branch ${branchRoot} failed: ${getErrorMessage(err)}`);
7758
+ }
7759
+ }
6185
7760
  async function loadOrCreateBranchContext(options) {
6186
7761
  if (readsFromCheckout(options.config)) {
6187
7762
  const cwd = process.cwd();
@@ -6239,11 +7814,11 @@ async function resolveBranchRoot(config) {
6239
7814
  }
6240
7815
 
6241
7816
  // dist/content-listing.js
6242
- import fs17 from "node:fs/promises";
6243
- import path19 from "node:path";
7817
+ import fs19 from "node:fs/promises";
7818
+ import path21 from "node:path";
6244
7819
  import matter3 from "gray-matter";
6245
7820
  import { parse as yamlParse2 } from "yaml";
6246
- var log7 = createDebugLogger({ prefix: "ContentListing" });
7821
+ var log8 = createDebugLogger({ prefix: "ContentListing" });
6247
7822
  var normalizePath = (root, target) => {
6248
7823
  const result = validateAndNormalizePath(root, target);
6249
7824
  if (!result.valid) {
@@ -6253,7 +7828,7 @@ var normalizePath = (root, target) => {
6253
7828
  };
6254
7829
  var readEntryData = async (filePath, format, bodyFieldName = "body") => {
6255
7830
  try {
6256
- const raw = await fs17.readFile(filePath, "utf8");
7831
+ const raw = await fs19.readFile(filePath, "utf8");
6257
7832
  if (format === "json") {
6258
7833
  return asRecord(JSON.parse(raw));
6259
7834
  }
@@ -6269,13 +7844,17 @@ var readEntryData = async (filePath, format, bodyFieldName = "body") => {
6269
7844
  } catch (err) {
6270
7845
  if (isNotFoundError(err))
6271
7846
  return {};
6272
- log7.warn("readEntryData", `Failed to read entry data from ${filePath}: ${getErrorMessage(err)}`);
7847
+ log8.warn("readEntryData", `Failed to read entry data from ${filePath}: ${getErrorMessage(err)}`);
6273
7848
  return {};
6274
7849
  }
6275
7850
  };
6276
- var createReferenceResolver = (branchRoot, flatSchema, contentRootName) => ({
7851
+ var createReferenceResolver = (branchRoot, flatSchema, contentRootName, visibility) => ({
6277
7852
  store: new ContentStore(branchRoot, flatSchema, { contentRootName }),
6278
- cache: createReferenceResolveCache()
7853
+ cache: createReferenceResolveCache(),
7854
+ // The predicate that hides an entry from the listing is the one that restricts it as a
7855
+ // reference target, so a target is never shown in a sibling's data that its own listing
7856
+ // row would have hidden.
7857
+ access: visibility?.shouldInclude
6279
7858
  });
6280
7859
  var resolveCollectionItemReferences = async (items, collection, resolver) => Promise.all(items.map(async (item) => {
6281
7860
  const fields = collection.entries?.find((e) => e.name === item.entryType)?.schema;
@@ -6283,7 +7862,7 @@ var resolveCollectionItemReferences = async (items, collection, resolver) => Pro
6283
7862
  return item;
6284
7863
  return {
6285
7864
  ...item,
6286
- data: await resolver.store.resolveReferences(item.data, fields, resolver.cache)
7865
+ data: await resolver.store.resolveReferences(item.data, fields, resolver.cache, resolver.access)
6287
7866
  };
6288
7867
  }));
6289
7868
  async function listEntries(branchRoot, flatSchema, contentRootName, options, visibility) {
@@ -6294,10 +7873,10 @@ async function listEntries(branchRoot, flatSchema, contentRootName, options, vis
6294
7873
  const collections = flatSchema.filter((item) => item.type === "collection" && item.entries !== void 0 && (item.logicalPath === rootPath || item.logicalPath.startsWith(`${rootPath}/`)));
6295
7874
  const shouldInclude = visibility?.shouldInclude;
6296
7875
  const skippedFiles = [];
6297
- const resolver = options?.resolveReferences ? createReferenceResolver(branchRoot, flatSchema, contentRootName) : null;
7876
+ const resolver = options?.resolveReferences ? createReferenceResolver(branchRoot, flatSchema, contentRootName, visibility) : null;
6298
7877
  const collectionResults = await Promise.all(collections.map(async (collection) => {
6299
7878
  const entries = await listCollectionEntries(branchRoot, collection, (file) => skippedFiles.push(file));
6300
- const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.physicalPath)) : entries;
7879
+ const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.logicalPath)) : entries;
6301
7880
  const resolved = resolver ? await resolveCollectionItemReferences(visible, collection, resolver) : visible;
6302
7881
  return resolved.map((entry) => ({ entry, collection }));
6303
7882
  }));
@@ -6375,7 +7954,7 @@ var listCollectionEntries = async (root, collection, onSkip) => {
6375
7954
  normalizePath(root, collectionRoot);
6376
7955
  let dirents;
6377
7956
  try {
6378
- dirents = await fs17.readdir(collectionRoot, { withFileTypes: true });
7957
+ dirents = await fs19.readdir(collectionRoot, { withFileTypes: true });
6379
7958
  } catch (err) {
6380
7959
  if (isNotFoundError(err))
6381
7960
  return [];
@@ -6383,11 +7962,11 @@ var listCollectionEntries = async (root, collection, onSkip) => {
6383
7962
  }
6384
7963
  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));
6385
7964
  const entries = await Promise.all(files.map(async (file) => {
6386
- const absolutePath = path19.join(collectionRoot, file.name);
7965
+ const absolutePath = path21.join(collectionRoot, file.name);
6387
7966
  const relativePath = normalizePath(root, absolutePath);
6388
7967
  const parsed = parseTypedFilename(file.name, entryTypes);
6389
7968
  if (!parsed) {
6390
- log7.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)`);
7969
+ log8.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)`);
6391
7970
  if (looksLikeMalformedEntry(file.name, entryTypes)) {
6392
7971
  onSkip?.({ filename: file.name, collectionPath: collection.logicalPath });
6393
7972
  }
@@ -6398,11 +7977,11 @@ var listCollectionEntries = async (root, collection, onSkip) => {
6398
7977
  const format = entryType?.format || "json";
6399
7978
  const bodyField = entryType?.schema ? findBodyFieldName(entryType.schema) : "body";
6400
7979
  const [stats, data] = await Promise.all([
6401
- fs17.stat(absolutePath),
7980
+ fs19.stat(absolutePath),
6402
7981
  readEntryData(absolutePath, format, bodyField)
6403
7982
  ]);
6404
7983
  const item = {
6405
- logicalPath: `${collection.logicalPath}/${slug}`,
7984
+ logicalPath: entryLogicalPath(collection.logicalPath, slug),
6406
7985
  contentId,
6407
7986
  slug,
6408
7987
  collectionPath: collection.logicalPath,
@@ -6422,7 +8001,8 @@ var listCollectionEntries = async (root, collection, onSkip) => {
6422
8001
  init_config2();
6423
8002
 
6424
8003
  // dist/assets/transform-directives.js
6425
- var MAX_INPUT_PIXELS = 4096 * 4096;
8004
+ var MAX_INPUT_PIXELS = 6e3 * 4e3;
8005
+ var CROP_PRECISION = 4;
6426
8006
  function isValidCropRect(x, y, w, h) {
6427
8007
  for (const n of [x, y, w, h]) {
6428
8008
  if (!Number.isFinite(n) || n < 0 || n > 1)
@@ -6434,6 +8014,21 @@ function isValidCropRect(x, y, w, h) {
6434
8014
  return false;
6435
8015
  return true;
6436
8016
  }
8017
+ function roundToCropPrecision(n) {
8018
+ const factor = 10 ** CROP_PRECISION;
8019
+ return Math.round(n * factor) / factor;
8020
+ }
8021
+ function roundCropRect(rect) {
8022
+ const x = roundToCropPrecision(rect.x);
8023
+ const y = roundToCropPrecision(rect.y);
8024
+ let w = roundToCropPrecision(rect.w);
8025
+ let h = roundToCropPrecision(rect.h);
8026
+ if (x + w > 1)
8027
+ w = roundToCropPrecision(1 - x);
8028
+ if (y + h > 1)
8029
+ h = roundToCropPrecision(1 - y);
8030
+ return { x, y, w, h };
8031
+ }
6437
8032
 
6438
8033
  // dist/validation/field-traversal.js
6439
8034
  function isPlainRecord2(value) {
@@ -6466,32 +8061,35 @@ function isValidImageCropValue(value) {
6466
8061
  if (typeof x !== "number" || typeof y !== "number" || typeof w !== "number" || typeof h !== "number") {
6467
8062
  return false;
6468
8063
  }
6469
- return isValidCropRect(x, y, w, h);
8064
+ if (!isValidCropRect(x, y, w, h))
8065
+ return false;
8066
+ const rounded = roundCropRect({ x, y, w, h });
8067
+ return isValidCropRect(rounded.x, rounded.y, rounded.w, rounded.h);
6470
8068
  }
6471
- function validateImageValue(field, value, path22) {
8069
+ function validateImageValue(field, value, path24) {
6472
8070
  if (!isPlainRecord3(value)) {
6473
- return [{ fieldPath: path22, message: "Expected an image object with { src, alt }" }];
8071
+ return [{ fieldPath: path24, message: "Expected an image object with { src, alt }" }];
6474
8072
  }
6475
8073
  const errors = [];
6476
8074
  if (typeof value.src !== "string" || value.src.trim() === "") {
6477
- errors.push({ fieldPath: `${path22}.src`, message: "Image src is required" });
8075
+ errors.push({ fieldPath: `${path24}.src`, message: "Image src is required" });
6478
8076
  }
6479
8077
  if (typeof value.alt !== "string") {
6480
- errors.push({ fieldPath: `${path22}.alt`, message: "Image alt text is required" });
8078
+ errors.push({ fieldPath: `${path24}.alt`, message: "Image alt text is required" });
6481
8079
  } else if (value.alt.trim() === "" && field.altOptional !== true) {
6482
- errors.push({ fieldPath: `${path22}.alt`, message: "Image alt text is required" });
8080
+ errors.push({ fieldPath: `${path24}.alt`, message: "Image alt text is required" });
6483
8081
  }
6484
8082
  if (value.width !== void 0 && !isPositiveInt(value.width)) {
6485
- errors.push({ fieldPath: `${path22}.width`, message: "Image width must be a positive integer" });
8083
+ errors.push({ fieldPath: `${path24}.width`, message: "Image width must be a positive integer" });
6486
8084
  }
6487
8085
  if (value.height !== void 0 && !isPositiveInt(value.height)) {
6488
8086
  errors.push({
6489
- fieldPath: `${path22}.height`,
8087
+ fieldPath: `${path24}.height`,
6490
8088
  message: "Image height must be a positive integer"
6491
8089
  });
6492
8090
  }
6493
8091
  if (value.crop !== void 0 && !isValidImageCropValue(value.crop)) {
6494
- errors.push({ fieldPath: `${path22}.crop`, message: "Invalid image crop rect" });
8092
+ errors.push({ fieldPath: `${path24}.crop`, message: "Invalid image crop rect" });
6495
8093
  }
6496
8094
  return errors;
6497
8095
  }
@@ -6503,44 +8101,44 @@ function referenceValueId(value) {
6503
8101
  return void 0;
6504
8102
  }
6505
8103
  var selectOptionValues = (field) => (field.options ?? []).map((opt) => typeof opt === "string" ? opt : opt.value);
6506
- function validateScalar(field, value, path22) {
8104
+ function validateScalar(field, value, path24) {
6507
8105
  switch (field.type) {
6508
8106
  case "number":
6509
8107
  if (typeof value !== "number" || !Number.isFinite(value)) {
6510
- return [{ fieldPath: path22, message: "Expected a number" }];
8108
+ return [{ fieldPath: path24, message: "Expected a number" }];
6511
8109
  }
6512
8110
  return [];
6513
8111
  case "boolean":
6514
8112
  if (typeof value !== "boolean") {
6515
- return [{ fieldPath: path22, message: "Expected true or false" }];
8113
+ return [{ fieldPath: path24, message: "Expected true or false" }];
6516
8114
  }
6517
8115
  return [];
6518
8116
  case "datetime":
6519
8117
  if (typeof value !== "string" || value !== "" && Number.isNaN(Date.parse(value))) {
6520
- return [{ fieldPath: path22, message: "Expected a valid date/time string" }];
8118
+ return [{ fieldPath: path24, message: "Expected a valid date/time string" }];
6521
8119
  }
6522
8120
  return [];
6523
8121
  case "select": {
6524
8122
  if (typeof value !== "string") {
6525
- return [{ fieldPath: path22, message: "Expected a selection" }];
8123
+ return [{ fieldPath: path24, message: "Expected a selection" }];
6526
8124
  }
6527
8125
  const options = selectOptionValues(field);
6528
8126
  if (value !== "" && !options.includes(value)) {
6529
- return [{ fieldPath: path22, message: `Must be one of: ${options.join(", ")}` }];
8127
+ return [{ fieldPath: path24, message: `Must be one of: ${options.join(", ")}` }];
6530
8128
  }
6531
8129
  return [];
6532
8130
  }
6533
8131
  case "reference":
6534
8132
  if (referenceValueId(value) === void 0) {
6535
- return [{ fieldPath: path22, message: "Expected a reference id" }];
8133
+ return [{ fieldPath: path24, message: "Expected a reference id" }];
6536
8134
  }
6537
8135
  return [];
6538
8136
  case "image":
6539
- return validateImageValue(field, value, path22);
8137
+ return validateImageValue(field, value, path24);
6540
8138
  default:
6541
8139
  if (STRING_FIELD_TYPES.has(field.type)) {
6542
8140
  if (typeof value !== "string") {
6543
- return [{ fieldPath: path22, message: "Expected text" }];
8141
+ return [{ fieldPath: path24, message: "Expected text" }];
6544
8142
  }
6545
8143
  }
6546
8144
  return [];
@@ -6563,12 +8161,12 @@ function validateEntryData(fields, data, pathPrefix = "") {
6563
8161
  errors.push(...validateEntryData(field.fields, data, pathPrefix));
6564
8162
  continue;
6565
8163
  }
6566
- const path22 = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
8164
+ const path24 = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
6567
8165
  const value = data[field.name];
6568
8166
  const isRequired = "required" in field && field.required === true;
6569
8167
  if (isRequired && isEmptyForRequired(field, value)) {
6570
8168
  const message = Array.isArray(value) ? "At least one item is required" : "This field is required";
6571
- errors.push({ fieldPath: path22, message });
8169
+ errors.push({ fieldPath: path24, message });
6572
8170
  continue;
6573
8171
  }
6574
8172
  if (value === void 0 || value === null)
@@ -6576,11 +8174,11 @@ function validateEntryData(fields, data, pathPrefix = "") {
6576
8174
  if (field.type === "block") {
6577
8175
  const blockField = field;
6578
8176
  if (!Array.isArray(value)) {
6579
- errors.push({ fieldPath: path22, message: "Expected a list of blocks" });
8177
+ errors.push({ fieldPath: path24, message: "Expected a list of blocks" });
6580
8178
  continue;
6581
8179
  }
6582
8180
  value.forEach((item, index) => {
6583
- const itemPath = `${path22}[${index}]`;
8181
+ const itemPath = `${path24}[${index}]`;
6584
8182
  if (!isPlainRecord3(item)) {
6585
8183
  errors.push({ fieldPath: itemPath, message: "Expected a block object" });
6586
8184
  return;
@@ -6602,11 +8200,11 @@ function validateEntryData(fields, data, pathPrefix = "") {
6602
8200
  const objectField = field;
6603
8201
  if (objectField.list) {
6604
8202
  if (!Array.isArray(value)) {
6605
- errors.push({ fieldPath: path22, message: "Expected a list of items" });
8203
+ errors.push({ fieldPath: path24, message: "Expected a list of items" });
6606
8204
  continue;
6607
8205
  }
6608
8206
  value.forEach((item, index) => {
6609
- const itemPath = `${path22}[${index}]`;
8207
+ const itemPath = `${path24}[${index}]`;
6610
8208
  if (!isPlainRecord3(item)) {
6611
8209
  errors.push({ fieldPath: itemPath, message: "Expected an object" });
6612
8210
  return;
@@ -6616,27 +8214,27 @@ function validateEntryData(fields, data, pathPrefix = "") {
6616
8214
  }
6617
8215
  });
6618
8216
  } else if (!isPlainRecord3(value)) {
6619
- errors.push({ fieldPath: path22, message: "Expected an object" });
8217
+ errors.push({ fieldPath: path24, message: "Expected an object" });
6620
8218
  } else if (objectField.fields) {
6621
- errors.push(...validateEntryData(objectField.fields, value, path22));
8219
+ errors.push(...validateEntryData(objectField.fields, value, path24));
6622
8220
  }
6623
8221
  continue;
6624
8222
  }
6625
8223
  if ("list" in field && field.list) {
6626
8224
  if (!Array.isArray(value)) {
6627
- errors.push({ fieldPath: path22, message: "Expected a list of values" });
8225
+ errors.push({ fieldPath: path24, message: "Expected a list of values" });
6628
8226
  continue;
6629
8227
  }
6630
8228
  value.forEach((item, index) => {
6631
- errors.push(...validateScalar(field, item, `${path22}[${index}]`));
8229
+ errors.push(...validateScalar(field, item, `${path24}[${index}]`));
6632
8230
  });
6633
8231
  continue;
6634
8232
  }
6635
8233
  if (Array.isArray(value) && KNOWN_FIELD_TYPES.has(field.type)) {
6636
- errors.push({ fieldPath: path22, message: "Expected a single value, not a list" });
8234
+ errors.push({ fieldPath: path24, message: "Expected a single value, not a list" });
6637
8235
  continue;
6638
8236
  }
6639
- errors.push(...validateScalar(field, value, path22));
8237
+ errors.push(...validateScalar(field, value, path24));
6640
8238
  }
6641
8239
  return errors;
6642
8240
  }
@@ -6687,7 +8285,7 @@ These are likely abandoned create-scaffolds (empty entries left behind when a cr
6687
8285
  var GENERATED_RECORD_FILENAME = ".canopy-generated.json";
6688
8286
  async function readGeneratedRecord(outputDir) {
6689
8287
  try {
6690
- const raw = await fs18.readFile(path20.join(outputDir, GENERATED_RECORD_FILENAME), "utf-8");
8288
+ const raw = await fs20.readFile(path22.join(outputDir, GENERATED_RECORD_FILENAME), "utf-8");
6691
8289
  const parsed = JSON.parse(raw);
6692
8290
  if (typeof parsed !== "object" || parsed === null || !("files" in parsed))
6693
8291
  return [];
@@ -6701,19 +8299,19 @@ async function readGeneratedRecord(outputDir) {
6701
8299
  }
6702
8300
  async function writeGeneratedRecord(outputDir, files) {
6703
8301
  const record = { files: [...files].sort() };
6704
- await fs18.mkdir(outputDir, { recursive: true });
6705
- await fs18.writeFile(path20.join(outputDir, GENERATED_RECORD_FILENAME), `${JSON.stringify(record, null, 2)}
8302
+ await fs20.mkdir(outputDir, { recursive: true });
8303
+ await fs20.writeFile(path22.join(outputDir, GENERATED_RECORD_FILENAME), `${JSON.stringify(record, null, 2)}
6706
8304
  `, "utf-8");
6707
8305
  }
6708
8306
  async function removeEmptyDirsUpward(startDir, stopAt) {
6709
8307
  let current = startDir;
6710
8308
  while (current.startsWith(stopAt) && current !== stopAt) {
6711
8309
  try {
6712
- await fs18.rmdir(current);
8310
+ await fs20.rmdir(current);
6713
8311
  } catch {
6714
8312
  return;
6715
8313
  }
6716
- current = path20.dirname(current);
8314
+ current = path22.dirname(current);
6717
8315
  }
6718
8316
  }
6719
8317
  var SAFE_BUILD_ID = /^[A-Za-z0-9._-]{1,255}$/;
@@ -6775,9 +8373,9 @@ async function generateAIContentFiles(options) {
6775
8373
  entryLinkUrl: config.entryLinkUrl,
6776
8374
  ...resolveBuildStamp()
6777
8375
  });
6778
- const absoluteOutputDir = path20.resolve(outputDir) + path20.sep;
8376
+ const absoluteOutputDir = path22.resolve(outputDir) + path22.sep;
6779
8377
  const resolveOutputPath = (filePath) => {
6780
- const absolutePath = path20.resolve(path20.join(absoluteOutputDir, filePath));
8378
+ const absolutePath = path22.resolve(path22.join(absoluteOutputDir, filePath));
6781
8379
  if (!absolutePath.startsWith(absoluteOutputDir)) {
6782
8380
  throw new Error(`Path traversal detected in AI content output: ${filePath}`);
6783
8381
  }
@@ -6789,8 +8387,8 @@ async function generateAIContentFiles(options) {
6789
8387
  let fileCount = 0;
6790
8388
  for (const [filePath, content] of result.files) {
6791
8389
  const absolutePath = resolveOutputPath(filePath);
6792
- await fs18.mkdir(path20.dirname(absolutePath), { recursive: true });
6793
- await fs18.writeFile(absolutePath, content, "utf-8");
8390
+ await fs20.mkdir(path22.dirname(absolutePath), { recursive: true });
8391
+ await fs20.writeFile(absolutePath, content, "utf-8");
6794
8392
  fileCount++;
6795
8393
  }
6796
8394
  const currentSet = new Set(currentFiles);
@@ -6799,13 +8397,13 @@ async function generateAIContentFiles(options) {
6799
8397
  for (const filePath of stale) {
6800
8398
  const absolutePath = resolveOutputPath(filePath);
6801
8399
  try {
6802
- await fs18.unlink(absolutePath);
8400
+ await fs20.unlink(absolutePath);
6803
8401
  removedCount++;
6804
8402
  } catch (err) {
6805
8403
  if (!isNodeError(err) || err.code !== "ENOENT")
6806
8404
  throw err;
6807
8405
  }
6808
- await removeEmptyDirsUpward(path20.dirname(absolutePath), absoluteOutputDir);
8406
+ await removeEmptyDirsUpward(path22.dirname(absolutePath), absoluteOutputDir);
6809
8407
  }
6810
8408
  await writeGeneratedRecord(absoluteOutputDir, currentFiles);
6811
8409
  return { fileCount, removedCount, outputDir: absoluteOutputDir };
@@ -6816,7 +8414,7 @@ var jiti = createJiti(import.meta.url);
6816
8414
  async function generateAIContentCLI(options) {
6817
8415
  const { projectDir, outputDir = "public/ai", configPath, appDir = "app" } = options;
6818
8416
  console.log("\nCanopyCMS generate-ai-content\n");
6819
- const canopyConfigPath = path21.join(projectDir, "canopycms.config.ts");
8417
+ const canopyConfigPath = path23.join(projectDir, "canopycms.config.ts");
6820
8418
  let canopyConfigModule;
6821
8419
  try {
6822
8420
  canopyConfigModule = await jiti.import(canopyConfigPath);
@@ -6827,7 +8425,7 @@ async function generateAIContentCLI(options) {
6827
8425
  }
6828
8426
  const configExport = canopyConfigModule.default ?? canopyConfigModule.config ?? canopyConfigModule;
6829
8427
  const serverConfig = typeof configExport === "object" && configExport !== null && "server" in configExport ? configExport.server : configExport;
6830
- const schemasPath = path21.join(projectDir, appDir, "schemas.ts");
8428
+ const schemasPath = path23.join(projectDir, appDir, "schemas.ts");
6831
8429
  let entrySchemaRegistry = {};
6832
8430
  try {
6833
8431
  const schemasModule = await jiti.import(schemasPath);
@@ -6838,7 +8436,7 @@ async function generateAIContentCLI(options) {
6838
8436
  let aiConfig;
6839
8437
  if (configPath) {
6840
8438
  try {
6841
- const aiConfigModule = await jiti.import(path21.resolve(configPath));
8439
+ const aiConfigModule = await jiti.import(path23.resolve(configPath));
6842
8440
  aiConfig = aiConfigModule.aiContentConfig ?? aiConfigModule.default ?? aiConfigModule.config;
6843
8441
  } catch (err) {
6844
8442
  console.error(`Could not load AI config from ${configPath}`);
@@ -6855,7 +8453,7 @@ async function generateAIContentCLI(options) {
6855
8453
  console.error("Make sure canopycms.config.ts uses defineCanopyConfig().");
6856
8454
  process.exit(1);
6857
8455
  }
6858
- const resolvedOutput = path21.resolve(projectDir, outputDir);
8456
+ const resolvedOutput = path23.resolve(projectDir, outputDir);
6859
8457
  console.log(` Output: ${resolvedOutput}`);
6860
8458
  console.log(` Mode: ${serverConfig.mode ?? "dev"}`);
6861
8459
  const result = await generateAIContentFiles({