canopycms 0.0.67 → 0.0.68-int.100

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 (268) hide show
  1. package/README.md +21 -14
  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 +111 -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 +10 -1
  23. package/dist/api/github-sync.js +20 -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/index.d.ts +1 -1
  36. package/dist/assets/materialize.d.ts +122 -0
  37. package/dist/assets/materialize.js +305 -0
  38. package/dist/assets/store-local.d.ts +4 -0
  39. package/dist/assets/store-local.js +11 -0
  40. package/dist/assets/store-s3.d.ts +10 -1
  41. package/dist/assets/store-s3.js +55 -3
  42. package/dist/assets/transform-directives.d.ts +52 -13
  43. package/dist/assets/transform-directives.js +87 -27
  44. package/dist/assets/transform.d.ts +3 -3
  45. package/dist/assets/transform.js +13 -8
  46. package/dist/assets/types.d.ts +14 -1
  47. package/dist/auth/file-based-auth-cache.js +7 -8
  48. package/dist/authorization/content.d.ts +5 -4
  49. package/dist/authorization/content.js +5 -5
  50. package/dist/authorization/path.d.ts +11 -6
  51. package/dist/authorization/path.js +11 -6
  52. package/dist/authorization/types.d.ts +2 -2
  53. package/dist/branch-health.d.ts +7 -1
  54. package/dist/branch-health.js +8 -3
  55. package/dist/branch-metadata.d.ts +5 -5
  56. package/dist/branch-metadata.js +44 -36
  57. package/dist/branch-provisioning.d.ts +167 -0
  58. package/dist/branch-provisioning.js +501 -0
  59. package/dist/branch-schema-cache.d.ts +5 -3
  60. package/dist/branch-schema-cache.js +29 -11
  61. package/dist/branch-sparse.d.ts +23 -0
  62. package/dist/branch-sparse.js +118 -0
  63. package/dist/branch-workspace.d.ts +42 -3
  64. package/dist/branch-workspace.js +240 -94
  65. package/dist/build/asset-refs.d.ts +93 -0
  66. package/dist/build/asset-refs.js +251 -0
  67. package/dist/build-identity.d.ts +3 -0
  68. package/dist/build-identity.js +13 -0
  69. package/dist/cli/asset-refs.d.ts +30 -0
  70. package/dist/cli/asset-refs.js +168 -0
  71. package/dist/cli/cli.d.ts +2 -1
  72. package/dist/cli/cli.js +4843 -1870
  73. package/dist/cli/generate-ai-content.js +1584 -444
  74. package/dist/cli/github-app-manifest.d.ts +4 -3
  75. package/dist/cli/github-app-manifest.js +4 -3
  76. package/dist/cli/init.js +30 -27
  77. package/dist/cli/migrate.js +12 -8
  78. package/dist/cli/sync.js +83 -37
  79. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  80. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  81. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  82. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  83. package/dist/config/helpers.js +1 -2
  84. package/dist/config/schemas/config.d.ts +19 -32
  85. package/dist/config/schemas/config.js +9 -11
  86. package/dist/config/schemas/field.js +1 -0
  87. package/dist/config/schemas/media.d.ts +4 -13
  88. package/dist/config/schemas/media.js +5 -8
  89. package/dist/config/schemas/url.d.ts +4 -20
  90. package/dist/config/schemas/url.js +6 -22
  91. package/dist/config/types.d.ts +30 -20
  92. package/dist/config/validation.d.ts +6 -0
  93. package/dist/config/validation.js +37 -0
  94. package/dist/content-listing.d.ts +12 -12
  95. package/dist/content-listing.js +10 -6
  96. package/dist/content-reader.d.ts +11 -2
  97. package/dist/content-reader.js +22 -14
  98. package/dist/content-store.d.ts +37 -9
  99. package/dist/content-store.js +70 -43
  100. package/dist/content-tree.d.ts +1 -1
  101. package/dist/content-tree.js +2 -2
  102. package/dist/context.d.ts +27 -9
  103. package/dist/context.js +154 -70
  104. package/dist/editor/BranchManager.d.ts +7 -1
  105. package/dist/editor/BranchManager.js +29 -14
  106. package/dist/editor/CanopyEditor.d.ts +1 -1
  107. package/dist/editor/CanopyEditor.js +2 -6
  108. package/dist/editor/Editor.d.ts +4 -3
  109. package/dist/editor/Editor.js +24 -20
  110. package/dist/editor/FormRenderer.js +34 -18
  111. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  112. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  113. package/dist/editor/components/BranchesDrawer.js +4 -0
  114. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  115. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  116. package/dist/editor/context/AssetContext.d.ts +18 -35
  117. package/dist/editor/context/AssetContext.js +20 -29
  118. package/dist/editor/context/index.d.ts +1 -1
  119. package/dist/editor/context/index.js +1 -1
  120. package/dist/editor/editor-config.d.ts +1 -2
  121. package/dist/editor/editor-config.js +0 -23
  122. package/dist/editor/editor-utils.d.ts +24 -21
  123. package/dist/editor/editor-utils.js +75 -62
  124. package/dist/editor/fields/BlockField.d.ts +1 -0
  125. package/dist/editor/fields/BlockField.js +6 -4
  126. package/dist/editor/fields/CodeField.d.ts +1 -0
  127. package/dist/editor/fields/CodeField.js +2 -2
  128. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  129. package/dist/editor/fields/DateTimeField.js +2 -2
  130. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  131. package/dist/editor/fields/FieldDescription.js +11 -0
  132. package/dist/editor/fields/ImageField.d.ts +1 -0
  133. package/dist/editor/fields/ImageField.js +3 -2
  134. package/dist/editor/fields/InlineGroupField.js +4 -1
  135. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  136. package/dist/editor/fields/MarkdownField.js +115 -20
  137. package/dist/editor/fields/NumberField.d.ts +1 -0
  138. package/dist/editor/fields/NumberField.js +2 -2
  139. package/dist/editor/fields/NumberListField.d.ts +1 -0
  140. package/dist/editor/fields/NumberListField.js +2 -2
  141. package/dist/editor/fields/ObjectField.d.ts +1 -0
  142. package/dist/editor/fields/ObjectField.js +5 -2
  143. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  144. package/dist/editor/fields/ReferenceField.js +5 -4
  145. package/dist/editor/fields/SelectField.d.ts +1 -0
  146. package/dist/editor/fields/SelectField.js +2 -2
  147. package/dist/editor/fields/StringListField.d.ts +1 -0
  148. package/dist/editor/fields/StringListField.js +2 -2
  149. package/dist/editor/fields/TextField.d.ts +1 -0
  150. package/dist/editor/fields/TextField.js +2 -2
  151. package/dist/editor/fields/ToggleField.d.ts +1 -0
  152. package/dist/editor/fields/ToggleField.js +7 -4
  153. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  154. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  155. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  156. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  157. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  158. package/dist/editor/hooks/create-branch-request.js +52 -0
  159. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  160. package/dist/editor/hooks/useBranchActions.js +48 -17
  161. package/dist/editor/hooks/useBranchManager.d.ts +11 -0
  162. package/dist/editor/hooks/useBranchManager.js +80 -2
  163. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  164. package/dist/editor/hooks/useBranchesData.js +9 -3
  165. package/dist/editor/hooks/useCommentSystem.js +25 -6
  166. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  167. package/dist/editor/hooks/useDraftManager.js +193 -36
  168. package/dist/editor/hooks/useEntriesData.js +3 -3
  169. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  170. package/dist/editor/hooks/useEntryManager.js +34 -19
  171. package/dist/editor/media/AssetCard.d.ts +1 -1
  172. package/dist/editor/media/crop-math.d.ts +3 -4
  173. package/dist/editor/media/crop-math.js +11 -22
  174. package/dist/editor/media/editor-image-src.d.ts +7 -0
  175. package/dist/editor/media/editor-image-src.js +12 -0
  176. package/dist/editor/preview-asset-base.d.ts +8 -0
  177. package/dist/editor/preview-asset-base.js +13 -0
  178. package/dist/editor/preview-bridge.d.ts +8 -1
  179. package/dist/editor/preview-bridge.js +15 -2
  180. package/dist/editor/preview-path.d.ts +10 -0
  181. package/dist/editor/preview-path.js +20 -0
  182. package/dist/editor/theme.js +2 -2
  183. package/dist/entry-schema-registry.d.ts +4 -4
  184. package/dist/entry-schema-registry.js +10 -6
  185. package/dist/entry-schema.d.ts +27 -4
  186. package/dist/entry-schema.js +20 -3
  187. package/dist/git-manager.d.ts +173 -6
  188. package/dist/git-manager.js +594 -76
  189. package/dist/github-service.d.ts +8 -0
  190. package/dist/github-service.js +5 -1
  191. package/dist/http/handler.js +37 -13
  192. package/dist/http/index.d.ts +2 -0
  193. package/dist/http/index.js +2 -0
  194. package/dist/http/router.d.ts +2 -0
  195. package/dist/http/router.js +2 -1
  196. package/dist/http/types.d.ts +2 -0
  197. package/dist/http/worker-not-ready.d.ts +10 -0
  198. package/dist/http/worker-not-ready.js +24 -0
  199. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  200. package/dist/operating-mode/types.d.ts +1 -4
  201. package/dist/paths/branch-name.d.ts +5 -0
  202. package/dist/paths/branch-name.js +9 -0
  203. package/dist/paths/branch.d.ts +6 -2
  204. package/dist/paths/branch.js +16 -10
  205. package/dist/paths/index.d.ts +3 -3
  206. package/dist/paths/index.js +3 -3
  207. package/dist/paths/normalize.d.ts +7 -0
  208. package/dist/paths/normalize.js +9 -0
  209. package/dist/paths/validation.js +0 -2
  210. package/dist/reference-resolver.d.ts +5 -21
  211. package/dist/reference-resolver.js +9 -44
  212. package/dist/resolve-canopy-user.js +2 -1
  213. package/dist/schema/schema-store.d.ts +54 -31
  214. package/dist/schema/schema-store.js +77 -31
  215. package/dist/server.d.ts +24 -9
  216. package/dist/server.js +22 -8
  217. package/dist/services.d.ts +18 -6
  218. package/dist/services.js +85 -70
  219. package/dist/settings-workspace.d.ts +23 -3
  220. package/dist/settings-workspace.js +197 -45
  221. package/dist/static/seo.d.ts +2 -14
  222. package/dist/static/seo.js +2 -25
  223. package/dist/submission-attribution.d.ts +73 -0
  224. package/dist/submission-attribution.js +221 -0
  225. package/dist/sync-core.d.ts +12 -1
  226. package/dist/sync-core.js +31 -13
  227. package/dist/task-queue/worker-status.d.ts +8 -0
  228. package/dist/task-queue/worker-status.js +17 -0
  229. package/dist/types.d.ts +31 -2
  230. package/dist/utils/content-write-lock.d.ts +18 -7
  231. package/dist/utils/content-write-lock.js +18 -9
  232. package/dist/utils/debug.d.ts +8 -0
  233. package/dist/utils/debug.js +10 -2
  234. package/dist/utils/git.d.ts +32 -0
  235. package/dist/utils/git.js +42 -0
  236. package/dist/utils/occ-json-write.js +10 -2
  237. package/dist/utils/provision-log.d.ts +25 -0
  238. package/dist/utils/provision-log.js +49 -0
  239. package/dist/utils/provisioning-lock.d.ts +35 -5
  240. package/dist/utils/provisioning-lock.js +73 -12
  241. package/dist/utils/request-timing.d.ts +25 -0
  242. package/dist/utils/request-timing.js +101 -0
  243. package/dist/utils/sanitize-href.d.ts +8 -22
  244. package/dist/utils/sanitize-href.js +11 -28
  245. package/dist/utils/url-prefix.d.ts +30 -0
  246. package/dist/utils/url-prefix.js +61 -0
  247. package/dist/validation/entry-validator.js +9 -3
  248. package/dist/version.d.ts +1 -0
  249. package/dist/version.js +3 -0
  250. package/dist/worker/canopy-state.d.ts +45 -0
  251. package/dist/worker/canopy-state.js +75 -0
  252. package/dist/worker/cms-worker.d.ts +8 -3
  253. package/dist/worker/cms-worker.js +38 -2
  254. package/dist/worker/git-sync.d.ts +30 -12
  255. package/dist/worker/git-sync.js +253 -58
  256. package/dist/worker/history-rewrite.d.ts +1 -1
  257. package/dist/worker/history-rewrite.js +1 -1
  258. package/dist/worker/provisioned-workspace.d.ts +35 -0
  259. package/dist/worker/provisioned-workspace.js +50 -0
  260. package/dist/worker/rebase.d.ts +1 -1
  261. package/dist/worker/rebase.js +95 -23
  262. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  263. package/dist/worker/remote-git-maintenance.js +11 -0
  264. package/dist/worker/sparse-cone.d.ts +20 -0
  265. package/dist/worker/sparse-cone.js +132 -0
  266. package/dist/worker/task-runner.js +39 -4
  267. package/dist/worker/worker-context.d.ts +6 -1
  268. package/package.json +2 -1
@@ -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
 
@@ -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";
@@ -1193,12 +1203,15 @@ import fs2 from "node:fs/promises";
1193
1203
  import path2 from "node:path";
1194
1204
  import lockfile from "proper-lockfile";
1195
1205
  init_logger();
1196
- function provisioningLockOptions(lockPath, retries, onCompromised) {
1206
+ var PROVISIONING_LOCK_STALE_MS = 9e4;
1207
+ function provisioningLockOptions(lockPath, retries, onCompromised, staleMs) {
1197
1208
  return {
1198
1209
  lockfilePath: lockPath,
1199
1210
  realpath: false,
1200
1211
  retries,
1201
- stale: 3e4,
1212
+ stale: staleMs,
1213
+ // Fixed, not stale/2: every holder refreshes at 15s whatever threshold it judges others by.
1214
+ update: 15e3,
1202
1215
  // proper-lockfile invokes this from inside its refresh timer, so ANY throw escaping here is
1203
1216
  // an uncaught exception that kills the process. Call sites are told not to throw (see
1204
1217
  // OnLockCompromised); this makes it structural rather than a convention, and also covers the
@@ -1234,37 +1247,68 @@ function releaseIgnoringAlreadyReleased(release, lockPath) {
1234
1247
  }
1235
1248
  };
1236
1249
  }
1250
+ function branchProvisioningLockName(dirName) {
1251
+ return `.${dirName}.init.lock`;
1252
+ }
1237
1253
  async function acquireProvisioningLock(lockTargetDir2, lockName, onCompromised) {
1238
1254
  await fs2.mkdir(lockTargetDir2, { recursive: true });
1239
1255
  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));
1256
+ const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised, PROVISIONING_LOCK_STALE_MS));
1241
1257
  return releaseIgnoringAlreadyReleased(release, lockPath);
1242
1258
  }
1243
- async function tryAcquireProvisioningLock(lockTargetDir2, lockName, onCompromised) {
1244
- await fs2.mkdir(lockTargetDir2, { recursive: true });
1259
+ async function tryAcquireProvisioningLock(lockTargetDir2, lockName, onCompromised, staleMs = PROVISIONING_LOCK_STALE_MS, createParents = true) {
1260
+ if (createParents) {
1261
+ await fs2.mkdir(lockTargetDir2, { recursive: true });
1262
+ } else {
1263
+ await fs2.mkdir(lockTargetDir2).catch((err) => {
1264
+ if (!isNodeError(err) || err.code !== "EEXIST")
1265
+ throw err;
1266
+ });
1267
+ }
1245
1268
  const lockPath = path2.join(lockTargetDir2, lockName);
1246
- const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised));
1269
+ const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised, staleMs));
1247
1270
  return releaseIgnoringAlreadyReleased(release, lockPath);
1248
1271
  }
1272
+ var sleep = (ms) => new Promise((resolve2) => {
1273
+ setTimeout(resolve2, ms);
1274
+ });
1275
+ async function acquireProvisioningLockWithin(lockTargetDir2, lockName, waitMs, onCompromised) {
1276
+ const startedAt = Date.now();
1277
+ for (let attempt = 0; ; attempt++) {
1278
+ try {
1279
+ return await tryAcquireProvisioningLock(lockTargetDir2, lockName, onCompromised);
1280
+ } catch (err) {
1281
+ if (!isNodeError(err) || err.code !== "ELOCKED")
1282
+ throw err;
1283
+ const remaining = waitMs - (Date.now() - startedAt);
1284
+ if (remaining <= 0)
1285
+ throw err;
1286
+ const base = Math.min(50 * 2 ** attempt, 500);
1287
+ await sleep(Math.min(base * (0.5 + Math.random()), remaining));
1288
+ }
1289
+ }
1290
+ }
1249
1291
 
1250
1292
  // dist/utils/content-write-lock.js
1251
1293
  var META_DIR = ".canopy-meta";
1252
1294
  var CONTENT_WRITE_LOCK_NAME = "content-write.lock";
1253
1295
  var DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2e3;
1254
1296
  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.") {
1297
+ 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
1298
  super(message);
1299
+ this.outcome = outcome;
1257
1300
  this.name = "ContentWriteLockBusyError";
1258
1301
  }
1259
1302
  };
1260
1303
  function lockTargetDir(branchRoot) {
1261
1304
  return path3.join(path3.resolve(branchRoot), META_DIR);
1262
1305
  }
1263
- var sleep = (ms) => new Promise((resolve2) => {
1306
+ var sleep2 = (ms) => new Promise((resolve2) => {
1264
1307
  setTimeout(resolve2, ms);
1265
1308
  });
1309
+ var CONTENT_WRITE_LOCK_STALE_MS = 3e4;
1266
1310
  function tryAcquireContentWriteLock(branchRoot, onCompromised) {
1267
- return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised);
1311
+ return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised, CONTENT_WRITE_LOCK_STALE_MS, false);
1268
1312
  }
1269
1313
  async function acquireContentWriteLock(branchRoot, waitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, onCompromised) {
1270
1314
  const startedAt = Date.now();
@@ -1278,7 +1322,7 @@ async function acquireContentWriteLock(branchRoot, waitMs = DEFAULT_CONTENT_WRIT
1278
1322
  if (remaining <= 0)
1279
1323
  throw new ContentWriteLockBusyError();
1280
1324
  const base = Math.min(40 * 2 ** attempt, 250);
1281
- await sleep(Math.min(base * (0.5 + Math.random()), remaining));
1325
+ await sleep2(Math.min(base * (0.5 + Math.random()), remaining));
1282
1326
  }
1283
1327
  }
1284
1328
  }
@@ -1295,7 +1339,7 @@ async function withContentWriteLock(branchRoot, fn, waitMs = DEFAULT_CONTENT_WRI
1295
1339
  await release();
1296
1340
  }
1297
1341
  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.");
1342
+ 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
1343
  }
1300
1344
  return result;
1301
1345
  }
@@ -1345,8 +1389,21 @@ function buildResolvedReference(data, meta, body) {
1345
1389
  resolved.slug = meta.slug;
1346
1390
  resolved.collection = meta.collection;
1347
1391
  resolved.urlPath = meta.urlPath;
1392
+ delete resolved[RESTRICTED_REFERENCE_MARKER];
1348
1393
  return resolved;
1349
1394
  }
1395
+ var RESTRICTED_REFERENCE_MARKER = "unavailable";
1396
+ function buildRestrictedReference(meta, title) {
1397
+ return {
1398
+ id: meta.id,
1399
+ slug: meta.slug,
1400
+ collection: meta.collection,
1401
+ urlPath: meta.urlPath,
1402
+ title,
1403
+ unavailable: true,
1404
+ reason: "restricted"
1405
+ };
1406
+ }
1350
1407
  var SEO_GROUP_FIELDS = [
1351
1408
  {
1352
1409
  name: DEFAULT_SEO_FIELD_NAMES.title,
@@ -1399,6 +1456,50 @@ var SEO_GROUP_FIELDS = [
1399
1456
  }
1400
1457
  ];
1401
1458
 
1459
+ // dist/utils/title-field.js
1460
+ function isGroupField(field) {
1461
+ return field.type === "group";
1462
+ }
1463
+ function extractTitleFromSchema(fields, data) {
1464
+ return findTitleValue(fields, data);
1465
+ }
1466
+ function findTitleValue(fields, data) {
1467
+ for (const field of fields) {
1468
+ if (isGroupField(field)) {
1469
+ const result = findTitleValue(field.fields, data);
1470
+ if (result !== void 0)
1471
+ return result;
1472
+ } else if ("isTitle" in field && field.isTitle) {
1473
+ const value = data[field.name];
1474
+ return typeof value === "string" ? value : void 0;
1475
+ } else if (field.type === "object" && "fields" in field && field.fields && !field.list) {
1476
+ const nested = data[field.name];
1477
+ if (nested != null && typeof nested === "object" && !Array.isArray(nested)) {
1478
+ const result = findTitleValue(field.fields, nested);
1479
+ if (result !== void 0)
1480
+ return result;
1481
+ }
1482
+ }
1483
+ }
1484
+ return void 0;
1485
+ }
1486
+ function humanizeSlug(slug) {
1487
+ return slug.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim();
1488
+ }
1489
+ function resolveEntryTitle(data, options) {
1490
+ if (options?.schema) {
1491
+ const schemaTitle = extractTitleFromSchema(options.schema, data);
1492
+ if (schemaTitle)
1493
+ return schemaTitle;
1494
+ }
1495
+ const title = data.title ?? data.name;
1496
+ if (typeof title === "string")
1497
+ return title;
1498
+ if (options?.entryTypeLabel)
1499
+ return options.entryTypeLabel;
1500
+ return options?.slug ? humanizeSlug(options.slug) : "Untitled";
1501
+ }
1502
+
1402
1503
  // dist/utils/entry-url.js
1403
1504
  init_normalize();
1404
1505
  function isIndexSlug(slug) {
@@ -1416,8 +1517,8 @@ function computeEntryUrl(collection, slug, contentRoot) {
1416
1517
  if (slug && !isIndexSlug(slug)) {
1417
1518
  segments.push(slug);
1418
1519
  }
1419
- const path22 = segments.length > 0 ? `/${segments.join("/")}` : "/";
1420
- return path22.toLowerCase();
1520
+ const path24 = segments.length > 0 ? `/${segments.join("/")}` : "/";
1521
+ return path24.toLowerCase();
1421
1522
  }
1422
1523
 
1423
1524
  // dist/url-collision.js
@@ -1790,13 +1891,13 @@ function extractIdFromFilename(filename) {
1790
1891
  return null;
1791
1892
  }
1792
1893
  async function resolveCollectionPath(root, logicalPath) {
1793
- const fs19 = await import("node:fs/promises");
1794
- const path22 = await import("node:path");
1894
+ const fs21 = await import("node:fs/promises");
1895
+ const path24 = await import("node:path");
1795
1896
  const segments = logicalPath.split("/").filter(Boolean);
1796
1897
  let currentPath = root;
1797
1898
  for (const segment of segments) {
1798
1899
  try {
1799
- const entries = await fs19.readdir(currentPath, { withFileTypes: true });
1900
+ const entries = await fs21.readdir(currentPath, { withFileTypes: true });
1800
1901
  const matchingDir = entries.find((entry) => {
1801
1902
  if (!entry.isDirectory())
1802
1903
  return false;
@@ -1804,7 +1905,7 @@ async function resolveCollectionPath(root, logicalPath) {
1804
1905
  return logicalName === segment.toLowerCase();
1805
1906
  });
1806
1907
  if (matchingDir) {
1807
- currentPath = path22.join(currentPath, matchingDir.name);
1908
+ currentPath = path24.join(currentPath, matchingDir.name);
1808
1909
  } else {
1809
1910
  return null;
1810
1911
  }
@@ -2048,6 +2149,10 @@ var DebugLogger = class {
2048
2149
  throw new Error(errorMsg);
2049
2150
  }
2050
2151
  }
2152
+ /**
2153
+ * Keyed by label alone, so two overlapping timers with one label overwrite each other;
2154
+ * anything that can run concurrently uses {@link timed}.
2155
+ */
2051
2156
  time(label) {
2052
2157
  this.timers.set(label, Date.now());
2053
2158
  }
@@ -2062,12 +2167,16 @@ var DebugLogger = class {
2062
2167
  this.debug(category, `${label} completed`, { durationMs: duration });
2063
2168
  return duration;
2064
2169
  }
2170
+ /**
2171
+ * Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
2172
+ * call, so concurrent spans with the same label on one logger never overwrite each other.
2173
+ */
2065
2174
  async timed(category, label, fn) {
2066
- this.time(label);
2175
+ const start = Date.now();
2067
2176
  try {
2068
2177
  return await fn();
2069
2178
  } finally {
2070
- this.timeEnd(category, label);
2179
+ this.debug(category, `${label} completed`, { durationMs: Date.now() - start });
2071
2180
  }
2072
2181
  }
2073
2182
  };
@@ -2393,9 +2502,80 @@ var STATIC_DEPLOY_USER = Object.freeze({
2393
2502
  name: "Static Deploy"
2394
2503
  });
2395
2504
 
2505
+ // dist/utils/request-timing.js
2506
+ import { AsyncLocalStorage } from "node:async_hooks";
2507
+ var storage = new AsyncLocalStorage();
2508
+ var log2 = createDebugLogger({ prefix: "CanopyCMS" });
2509
+ async function timeRequestPhase(phase, fn) {
2510
+ const parent = storage.getStore();
2511
+ if (!parent)
2512
+ return fn();
2513
+ const key = parent.path ? `${parent.path}>${phase}` : phase;
2514
+ let total = parent.phases.get(key);
2515
+ if (!total) {
2516
+ total = { count: 0, ms: 0 };
2517
+ parent.phases.set(key, total);
2518
+ }
2519
+ const start = performance.now();
2520
+ try {
2521
+ return await storage.run({ ...parent, path: key }, fn);
2522
+ } finally {
2523
+ total.count += 1;
2524
+ total.ms += performance.now() - start;
2525
+ }
2526
+ }
2527
+
2528
+ // dist/utils/git.js
2529
+ import { simpleGit } from "simple-git";
2530
+ var NETWORK_SCHEME_PATTERN = /^(https?|ssh|git):\/\//i;
2531
+ var TRANSPORT_HELPER_PATTERN = /^[a-z][a-z0-9+.-]*::/i;
2532
+ var LEADING_OPTION_PATTERN = /^-/;
2533
+ var WINDOWS_DRIVE_PATTERN = /^[a-zA-Z]:[\\/]/;
2534
+ var COLON_BEFORE_SLASH_PATTERN = /^[^/\\\s]+:/;
2535
+ function isNetworkRemoteUrl(url) {
2536
+ if (/^file:\/\//i.test(url))
2537
+ return false;
2538
+ if (NETWORK_SCHEME_PATTERN.test(url))
2539
+ return true;
2540
+ if (TRANSPORT_HELPER_PATTERN.test(url))
2541
+ return true;
2542
+ if (LEADING_OPTION_PATTERN.test(url))
2543
+ return true;
2544
+ if (WINDOWS_DRIVE_PATTERN.test(url))
2545
+ return false;
2546
+ return COLON_BEFORE_SLASH_PATTERN.test(url);
2547
+ }
2548
+ var MISSING_REMOTE_REF_REASONS = ["couldn't find remote ref", "Couldn't find remote ref"];
2549
+ function isMissingRemoteRefFailure(message) {
2550
+ return MISSING_REMOTE_REF_REASONS.some((reason) => message.includes(reason));
2551
+ }
2552
+ var CANOPY_META_DIR = ".canopy-meta";
2553
+ async function stageAllExceptCanopyState(git) {
2554
+ await git.raw(["add", "-A", "--sparse"]);
2555
+ await git.raw(["reset", "-q", "--", CANOPY_META_DIR]);
2556
+ }
2557
+ async function detectHeadBranch(repoRoot, fallback = "main") {
2558
+ try {
2559
+ const git = simpleGit({ baseDir: repoRoot });
2560
+ const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
2561
+ return head && head !== "HEAD" ? head : fallback;
2562
+ } catch {
2563
+ return fallback;
2564
+ }
2565
+ }
2566
+ async function resolveBaseBranch(options) {
2567
+ if (options.defaultBaseBranch)
2568
+ return options.defaultBaseBranch;
2569
+ if (options.mode === "dev") {
2570
+ return detectHeadBranch(options.detectFrom ?? process.cwd());
2571
+ }
2572
+ return "main";
2573
+ }
2574
+
2396
2575
  // dist/branch-schema-cache.js
2397
2576
  var SCHEMA_CACHE_VERSION = 3;
2398
2577
  var MTIME_CHECK_DEBOUNCE_MS = 1e3;
2578
+ var SCHEMA_CACHE_FILE = "schema-cache.json";
2399
2579
  var SCHEMA_GENERATION_RESOURCE = "schema";
2400
2580
  async function isStaleByMtime(dir, cachedAt) {
2401
2581
  let entries;
@@ -2418,6 +2598,11 @@ async function isStaleByMtime(dir, cachedAt) {
2418
2598
  }
2419
2599
  return false;
2420
2600
  }
2601
+ async function schemaCacheDir(branchRoot) {
2602
+ const gitDir = path8.join(branchRoot, ".git");
2603
+ const isClone = await fs7.stat(gitDir).then((stat) => stat.isDirectory(), () => false);
2604
+ return isClone ? path8.join(gitDir, "canopycms") : path8.join(branchRoot, CANOPY_META_DIR);
2605
+ }
2421
2606
  var BranchSchemaCache = class {
2422
2607
  constructor(mode = "prod") {
2423
2608
  this.lastMtimeCheck = /* @__PURE__ */ new Map();
@@ -2426,7 +2611,7 @@ var BranchSchemaCache = class {
2426
2611
  /**
2427
2612
  * Whether to skip the on-disk cache for this branchRoot.
2428
2613
  *
2429
- * Never write `.canopy-meta/` at the project root, whichever entrypoint
2614
+ * Never write a schema cache at the project root, whichever entrypoint
2430
2615
  * produced the cwd branchRoot. branchRoot equals process.cwd() only in the
2431
2616
  * synthetic contexts static deployments and build phases use; a real branch
2432
2617
  * root is always nested under the workspace.
@@ -2436,7 +2621,7 @@ var BranchSchemaCache = class {
2436
2621
  }
2437
2622
  /** Get schema for a branch, from the cache when fresh, else resolved fresh. */
2438
2623
  async getSchema(branchRoot, entrySchemaRegistry, contentRootName = "content") {
2439
- return this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName);
2624
+ return timeRequestPhase("schema", () => this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName));
2440
2625
  }
2441
2626
  /**
2442
2627
  * Resolve the schema from disk. Protected rather than a direct resolveSchema
@@ -2450,8 +2635,7 @@ var BranchSchemaCache = class {
2450
2635
  const contentRoot = path8.join(branchRoot, contentRootName);
2451
2636
  const skipDiskCache = this.skipDiskCache(branchRoot);
2452
2637
  if (!skipDiskCache) {
2453
- const cacheDir = path8.join(branchRoot, ".canopy-meta");
2454
- const cachePath = path8.join(cacheDir, "schema-cache.json");
2638
+ const cachePath = path8.join(await schemaCacheDir(branchRoot), SCHEMA_CACHE_FILE);
2455
2639
  let cacheData = null;
2456
2640
  try {
2457
2641
  const cacheContent = await fs7.readFile(cachePath, "utf-8");
@@ -2493,7 +2677,7 @@ var BranchSchemaCache = class {
2493
2677
  const { skipDiskCache } = options;
2494
2678
  const contentRoot = path8.join(branchRoot, contentRootName);
2495
2679
  const read = skipDiskCache ? null : await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
2496
- const result = await this.resolveFresh(contentRoot, entrySchemaRegistry);
2680
+ const result = await timeRequestPhase("resolve", () => this.resolveFresh(contentRoot, entrySchemaRegistry));
2497
2681
  if (!isValidSchema(result.schema)) {
2498
2682
  throw new Error(`No schema found in ${contentRoot}. Create .collection.json files with references to field schemas defined in your entry schema registry.`);
2499
2683
  }
@@ -2504,9 +2688,9 @@ var BranchSchemaCache = class {
2504
2688
  }
2505
2689
  const flatSchema = flattenSchema(result.schema, contentRootName);
2506
2690
  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(() => {
2691
+ const cacheDir = await schemaCacheDir(branchRoot);
2692
+ const cachePath = path8.join(cacheDir, SCHEMA_CACHE_FILE);
2693
+ await fs7.unlink(path8.join(branchRoot, CANOPY_META_DIR, "schema-cache.stale")).catch(() => {
2510
2694
  });
2511
2695
  if (read && read.ok) {
2512
2696
  const newCache = {
@@ -2634,7 +2818,6 @@ function validateAndNormalizePath(root, target) {
2634
2818
 
2635
2819
  // dist/paths/branch.js
2636
2820
  init_operating_mode();
2637
- import fs9 from "node:fs/promises";
2638
2821
  import path10 from "node:path";
2639
2822
 
2640
2823
  // dist/paths/branch-name.js
@@ -2644,6 +2827,11 @@ function sanitizeBranchName(branchName) {
2644
2827
  const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
2645
2828
  return trimmedDots || "branch";
2646
2829
  }
2830
+ var RESERVED_SETTINGS_BRANCH_PREFIX = "canopycms-settings-";
2831
+ function isSettingsBranchName(branchName, settingsBranchName) {
2832
+ const sanitized = sanitizeBranchName(branchName);
2833
+ return sanitized.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX) || settingsBranchName !== void 0 && sanitized === sanitizeBranchName(settingsBranchName);
2834
+ }
2647
2835
 
2648
2836
  // dist/paths/branch.js
2649
2837
  var BranchPathError = class extends Error {
@@ -2655,6 +2843,9 @@ function resolveBranchPath(options) {
2655
2843
  if (options.branchName.includes("..")) {
2656
2844
  throw new BranchPathError("Branch name cannot contain traversal segments");
2657
2845
  }
2846
+ if (isSettingsBranchName(options.branchName)) {
2847
+ throw new BranchPathError("Settings branches are not content branches");
2848
+ }
2658
2849
  const safeBranch = sanitizeBranchName(options.branchName);
2659
2850
  const strategy = operatingStrategy(options.mode);
2660
2851
  const baseRoot = resolveContentBranchesRoot(options.mode, options.basePathOverride);
@@ -2670,11 +2861,6 @@ function resolveBranchPath(options) {
2670
2861
  }
2671
2862
  return { branchRoot, baseRoot: normalizedBase, branchName: safeBranch };
2672
2863
  }
2673
- async function ensureBranchRoot(options) {
2674
- const result = resolveBranchPath(options);
2675
- await fs9.mkdir(result.branchRoot, { recursive: true });
2676
- return result;
2677
- }
2678
2864
 
2679
2865
  // dist/content-store.js
2680
2866
  async function withLocks(keys, fn) {
@@ -2884,7 +3070,7 @@ var ContentStore = class {
2884
3070
  async findEntryPathsById(dir, id) {
2885
3071
  let entries;
2886
3072
  try {
2887
- entries = await fs10.readdir(dir, { withFileTypes: true });
3073
+ entries = await fs9.readdir(dir, { withFileTypes: true });
2888
3074
  } catch (err) {
2889
3075
  if (isNodeError(err) && err.code === "ENOENT")
2890
3076
  return [];
@@ -2903,11 +3089,11 @@ var ContentStore = class {
2903
3089
  getSchemaItems() {
2904
3090
  return this.schemaIndex.values();
2905
3091
  }
2906
- assertSchemaItem(path22) {
2907
- const normalized = normalizeFilesystemPath(path22);
3092
+ assertSchemaItem(path24) {
3093
+ const normalized = normalizeFilesystemPath(path24);
2908
3094
  const item = this.schemaIndex.get(normalized);
2909
3095
  if (!item) {
2910
- throw new ContentStoreError(`Unknown schema item: ${path22}`, "NO_SCHEMA_ITEM");
3096
+ throw new ContentStoreError(`Unknown schema item: ${path24}`, "NO_SCHEMA_ITEM");
2911
3097
  }
2912
3098
  return item;
2913
3099
  }
@@ -3082,7 +3268,7 @@ var ContentStore = class {
3082
3268
  let existingEntryType;
3083
3269
  let foundExisting = false;
3084
3270
  if (!id) {
3085
- const entries = await fs10.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
3271
+ const entries = await fs9.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
3086
3272
  const existingFile = entries.find((entry) => {
3087
3273
  if (entry.isDirectory())
3088
3274
  return false;
@@ -3116,6 +3302,7 @@ var ContentStore = class {
3116
3302
  return {
3117
3303
  absolutePath: resolved,
3118
3304
  relativePath: path11.relative(this.root, resolved),
3305
+ logicalPath: entryLogicalPath(schemaItem.logicalPath, safeSlug),
3119
3306
  id,
3120
3307
  entryTypeName: finalEntryTypeName,
3121
3308
  existed
@@ -3145,11 +3332,15 @@ var ContentStore = class {
3145
3332
  const schemaItem = this.assertSchemaItem(schemaPath);
3146
3333
  return await this.buildPaths(schemaItem, slug);
3147
3334
  }
3335
+ /**
3336
+ * Read one entry. References resolve unless `resolveReferences: false`; a target that
3337
+ * `referenceAccess` denies resolves to a `RestrictedReference` rather than its data.
3338
+ */
3148
3339
  async read(collectionPath, slug = "", options = {}) {
3149
3340
  const schemaItem = this.assertSchemaItem(collectionPath);
3150
3341
  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");
3342
+ const stat = await fs9.stat(absolutePath);
3343
+ const raw = await fs9.readFile(absolutePath, "utf8");
3153
3344
  let doc;
3154
3345
  let format;
3155
3346
  let fields;
@@ -3206,7 +3397,7 @@ var ContentStore = class {
3206
3397
  };
3207
3398
  }
3208
3399
  if (options.resolveReferences !== false) {
3209
- doc.data = await this.resolveReferencesInData(doc.data, fields);
3400
+ doc.data = await this.resolveReferencesInData(doc.data, fields, void 0, options.referenceAccess);
3210
3401
  }
3211
3402
  doc.version = stat.mtimeMs;
3212
3403
  return doc;
@@ -3275,10 +3466,10 @@ var ContentStore = class {
3275
3466
  }
3276
3467
  await this.assertUrlPathAvailable(path11.dirname(absolutePath), chosenSlug);
3277
3468
  }
3278
- await fs10.mkdir(path11.dirname(absolutePath), { recursive: true });
3469
+ await fs9.mkdir(path11.dirname(absolutePath), { recursive: true });
3279
3470
  if (input.expectedVersion !== void 0) {
3280
3471
  try {
3281
- const existing = await fs10.stat(absolutePath);
3472
+ const existing = await fs9.stat(absolutePath);
3282
3473
  if (input.expectedVersion === null) {
3283
3474
  throw new ContentConflictError("An entry with this slug already exists");
3284
3475
  }
@@ -3335,13 +3526,13 @@ var ContentStore = class {
3335
3526
  }
3336
3527
  }
3337
3528
  if (staleOldAbsPath) {
3338
- await fs10.unlink(staleOldAbsPath).catch((err) => {
3529
+ await fs9.unlink(staleOldAbsPath).catch((err) => {
3339
3530
  if (!isNodeError(err) || err.code !== "ENOENT")
3340
3531
  throw err;
3341
3532
  });
3342
3533
  }
3343
3534
  await this.recordOwnMutation(liveIndex);
3344
- const afterStat = await fs10.stat(absolutePath);
3535
+ const afterStat = await fs9.stat(absolutePath);
3345
3536
  const base = {
3346
3537
  collection: schemaItem.logicalPath,
3347
3538
  collectionName: schemaItem.name,
@@ -3418,7 +3609,7 @@ var ContentStore = class {
3418
3609
  const schemaItem = this.assertSchemaItem(collectionPath);
3419
3610
  const { absolutePath } = await this.buildPaths(schemaItem, slug);
3420
3611
  try {
3421
- await fs10.access(absolutePath);
3612
+ await fs9.access(absolutePath);
3422
3613
  return true;
3423
3614
  } catch (err) {
3424
3615
  if (isNodeError(err) && err.code === "ENOENT")
@@ -3453,7 +3644,7 @@ var ContentStore = class {
3453
3644
  const collectionRoot = await resolveCollectionPath(this.root, collectionPath);
3454
3645
  if (!collectionRoot)
3455
3646
  return 0;
3456
- const entries = await fs10.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
3647
+ const entries = await fs9.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
3457
3648
  return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
3458
3649
  }
3459
3650
  /**
@@ -3471,7 +3662,7 @@ var ContentStore = class {
3471
3662
  const collection = this.assertCollection(collectionPath);
3472
3663
  const prePass = await this.buildPaths(collection, slug);
3473
3664
  if (!prePass.existed) {
3474
- await fs10.unlink(prePass.absolutePath);
3665
+ await fs9.unlink(prePass.absolutePath);
3475
3666
  return;
3476
3667
  }
3477
3668
  let lockKey = this.entryLockKey(collection, slug, prePass);
@@ -3487,7 +3678,7 @@ var ContentStore = class {
3487
3678
  return RETRY_KEY;
3488
3679
  }
3489
3680
  const { absolutePath, relativePath } = inLock;
3490
- await fs10.unlink(absolutePath);
3681
+ await fs9.unlink(absolutePath);
3491
3682
  const liveIndex = this._idIndex;
3492
3683
  const id = liveIndex.findByPath(relativePath);
3493
3684
  if (id) {
@@ -3559,7 +3750,7 @@ var ContentStore = class {
3559
3750
  const parentDir = path11.dirname(currentPath);
3560
3751
  const newPath = path11.join(parentDir, newFilename);
3561
3752
  try {
3562
- const entries = await fs10.readdir(parentDir, { withFileTypes: true });
3753
+ const entries = await fs9.readdir(parentDir, { withFileTypes: true });
3563
3754
  for (const entry of entries) {
3564
3755
  if (entry.isDirectory())
3565
3756
  continue;
@@ -3574,14 +3765,14 @@ var ContentStore = class {
3574
3765
  }
3575
3766
  await this.assertUrlPathAvailable(parentDir, safeNewSlug);
3576
3767
  try {
3577
- await fs10.link(currentPath, newPath);
3768
+ await fs9.link(currentPath, newPath);
3578
3769
  } catch (err) {
3579
3770
  if (isNodeError(err) && err.code === "EEXIST") {
3580
3771
  throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
3581
3772
  }
3582
3773
  throw err;
3583
3774
  }
3584
- await fs10.unlink(currentPath);
3775
+ await fs9.unlink(currentPath);
3585
3776
  const newRelativePath = path11.relative(this.root, newPath);
3586
3777
  const liveIndex = this._idIndex;
3587
3778
  const entryId = liveIndex.findByPath(currentRelPath);
@@ -3660,20 +3851,25 @@ var ContentStore = class {
3660
3851
  * a target referenced by many entries is read once; omit `cache` for exactly `read()`'s
3661
3852
  * unmemoized behavior.
3662
3853
  *
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.
3854
+ * `access` applies to every target exactly as in `read()`; see {@link ReferenceTargetAccess}.
3667
3855
  */
3668
- async resolveReferences(data, fields, cache) {
3669
- return this.resolveReferencesInData(data, fields, cache);
3856
+ async resolveReferences(data, fields, cache, access) {
3857
+ return this.resolveReferencesInData(data, fields, cache, access);
3670
3858
  }
3671
- async resolveReferencesInData(data, fields, cache) {
3859
+ /**
3860
+ * Resolve one reference id the way a reference field resolves it, for the editor's
3861
+ * live-preview endpoint (api/resolve-references.ts), which has ids but no field to walk. Null
3862
+ * when the id names no entry. No body is embedded, since there is no field to ask for one.
3863
+ */
3864
+ async resolveReferenceTarget(id, access) {
3865
+ return this.resolveSingleReference(id, await this.idIndex(), false, void 0, access);
3866
+ }
3867
+ async resolveReferencesInData(data, fields, cache, access) {
3672
3868
  const resolved = { ...data };
3673
3869
  const idIndex = await this.idIndex();
3674
3870
  for (const field of fields) {
3675
3871
  if (field.type === "group") {
3676
- const groupResolved = await this.resolveReferencesInData(resolved, field.fields, cache);
3872
+ const groupResolved = await this.resolveReferencesInData(resolved, field.fields, cache, access);
3677
3873
  Object.assign(resolved, groupResolved);
3678
3874
  continue;
3679
3875
  }
@@ -3681,18 +3877,18 @@ var ContentStore = class {
3681
3877
  if (field.type === "reference") {
3682
3878
  const includeBody = field.includeBody === true;
3683
3879
  if (typeof value === "string" && value) {
3684
- resolved[field.name] = await this.resolveSingleReference(value, idIndex, includeBody, cache);
3880
+ resolved[field.name] = await this.resolveSingleReference(value, idIndex, includeBody, cache, access);
3685
3881
  } 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));
3882
+ resolved[field.name] = await Promise.all(value.map((id) => typeof id === "string" ? this.resolveSingleReference(id, idIndex, includeBody, cache, access) : null));
3687
3883
  }
3688
3884
  } else if (field.type === "object" && value) {
3689
3885
  const objectField = field;
3690
3886
  if (!objectField.fields)
3691
3887
  continue;
3692
3888
  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));
3889
+ resolved[field.name] = await Promise.all(value.map((item) => typeof item === "object" && item !== null ? this.resolveReferencesInData(item, objectField.fields, cache, access) : item));
3694
3890
  } else if (typeof value === "object") {
3695
- resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields, cache);
3891
+ resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields, cache, access);
3696
3892
  }
3697
3893
  } else if (field.type === "block" && Array.isArray(value)) {
3698
3894
  const blockField = field;
@@ -3705,7 +3901,7 @@ var ContentStore = class {
3705
3901
  return block;
3706
3902
  return {
3707
3903
  ...b,
3708
- value: await this.resolveReferencesInData(b.value, template.fields, cache)
3904
+ value: await this.resolveReferencesInData(b.value, template.fields, cache, access)
3709
3905
  };
3710
3906
  }));
3711
3907
  }
@@ -3736,13 +3932,13 @@ var ContentStore = class {
3736
3932
  * `includeBody` is opt-in per field: an embedded body sits inside the memoized object and is
3737
3933
  * cloned once per referencing entry.
3738
3934
  */
3739
- resolveSingleReference(id, idIndex, includeBody, cache) {
3935
+ resolveSingleReference(id, idIndex, includeBody, cache, access) {
3740
3936
  if (!cache)
3741
- return this.resolveSingleReferenceUncached(id, idIndex, includeBody);
3937
+ return this.resolveSingleReferenceUncached(id, idIndex, includeBody, access);
3742
3938
  const key = includeBody ? `${id}:body` : id;
3743
3939
  let pending = cache.get(key);
3744
3940
  if (!pending) {
3745
- pending = this.resolveSingleReferenceUncached(id, idIndex, includeBody);
3941
+ pending = this.resolveSingleReferenceUncached(id, idIndex, includeBody, access);
3746
3942
  cache.set(key, pending);
3747
3943
  }
3748
3944
  return pending.then((resolved) => resolved === null ? null : structuredClone(resolved));
@@ -3760,15 +3956,27 @@ var ContentStore = class {
3760
3956
  * A {@link ReferenceResolveCache} sits ABOVE this, never inside it, so every distinct id
3761
3957
  * still runs the full refresh-and-retry; a cache only makes repeats of the SAME id share it.
3762
3958
  */
3763
- async resolveSingleReferenceUncached(id, idIndex, includeBody) {
3764
- const first = await this.resolveSingleReferenceOnce(id, idIndex, includeBody);
3959
+ async resolveSingleReferenceUncached(id, idIndex, includeBody, access) {
3960
+ const first = await this.resolveSingleReferenceOnce(id, idIndex, includeBody, access);
3765
3961
  if (first !== STALE_LOOKUP)
3766
3962
  return first;
3767
3963
  await this.refreshIndexForSuspiciousLookup();
3768
- const second = await this.resolveSingleReferenceOnce(id, await this.idIndex(), includeBody);
3964
+ const second = await this.resolveSingleReferenceOnce(id, await this.idIndex(), includeBody, access);
3769
3965
  return second === STALE_LOOKUP ? null : second;
3770
3966
  }
3771
- async resolveSingleReferenceOnce(id, idIndex, includeBody) {
3967
+ /**
3968
+ * The entry type an entry file was written as, by its filename's type token, falling back to
3969
+ * the collection's default the way `read()` does for a legacy untyped file.
3970
+ */
3971
+ entryTypeOf(collectionPath, relativePath) {
3972
+ const schemaItem = this.assertSchemaItem(collectionPath);
3973
+ if (schemaItem.type === "entry-type")
3974
+ return schemaItem;
3975
+ const entries = schemaItem.entries;
3976
+ const typeName = extractEntryTypeFromFilename(path11.basename(relativePath));
3977
+ return entries?.find((e) => e.name === typeName) ?? getDefaultEntryType(schemaItem.entries);
3978
+ }
3979
+ async resolveSingleReferenceOnce(id, idIndex, includeBody, access) {
3772
3980
  try {
3773
3981
  const location = idIndex.findById(id);
3774
3982
  if (!location)
@@ -3779,13 +3987,22 @@ var ContentStore = class {
3779
3987
  const doc = await this.read(location.collection, location.slug, {
3780
3988
  resolveReferences: false
3781
3989
  });
3782
- const bodyForEmbed = includeBody && "body" in doc ? { fieldName: doc.bodyFieldName, value: doc.body } : void 0;
3783
- return buildResolvedReference(doc.data, {
3990
+ const meta = {
3784
3991
  id,
3785
3992
  slug: location.slug,
3786
3993
  collection: location.collection,
3787
3994
  urlPath: computeEntryUrl(location.collection, location.slug, this.contentRootName)
3788
- }, bodyForEmbed);
3995
+ };
3996
+ if (access && !access(entryLogicalPath(location.collection, location.slug))) {
3997
+ const entryType = this.entryTypeOf(location.collection, location.relativePath);
3998
+ return buildRestrictedReference(meta, resolveEntryTitle(doc.data, {
3999
+ schema: entryType?.schema,
4000
+ entryTypeLabel: entryType?.label,
4001
+ slug: location.slug
4002
+ }));
4003
+ }
4004
+ const bodyForEmbed = includeBody && "body" in doc ? { fieldName: doc.bodyFieldName, value: doc.body } : void 0;
4005
+ return buildResolvedReference(doc.data, meta, bodyForEmbed);
3789
4006
  } catch (error) {
3790
4007
  if (isNodeError(error) && error.code === "ENOENT")
3791
4008
  return STALE_LOOKUP;
@@ -3797,7 +4014,7 @@ var ContentStore = class {
3797
4014
 
3798
4015
  // dist/ai/generate.js
3799
4016
  import path12 from "node:path";
3800
- import fs11 from "node:fs/promises";
4017
+ import fs10 from "node:fs/promises";
3801
4018
  import { minimatch } from "minimatch";
3802
4019
 
3803
4020
  // dist/ai/strip-mdx.js
@@ -4353,7 +4570,7 @@ function yamlValue(value) {
4353
4570
  }
4354
4571
 
4355
4572
  // dist/entry-link-resolver.js
4356
- var log2 = createDebugLogger({ prefix: "EntryLinks" });
4573
+ var log3 = createDebugLogger({ prefix: "EntryLinks" });
4357
4574
  var BASE58_CHAR = "[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]";
4358
4575
  var ENTRY_LINK_PATTERN = new RegExp(`entry:(${BASE58_CHAR}{12})(#[^\\s)>"']*)?`, "g");
4359
4576
  function resolveEntryUrl(location, contentRoot) {
@@ -4367,7 +4584,7 @@ function resolveEntryLinksInText(text, idIndex, contentRoot, customResolver) {
4367
4584
  return part.text.replace(ENTRY_LINK_PATTERN, (_match, id, anchor) => {
4368
4585
  const location = idIndex.findById(id);
4369
4586
  if (!location || location.type !== "entry" || !location.collection || !location.slug) {
4370
- log2.warn("resolve", `Entry link target not found: entry:${id}`);
4587
+ log3.warn("resolve", `Entry link target not found: entry:${id}`);
4371
4588
  return anchor ?? "#";
4372
4589
  }
4373
4590
  let url;
@@ -4669,10 +4886,10 @@ function makeReadSibling(dir) {
4669
4886
  if (path12.dirname(abs) !== resolvedDir)
4670
4887
  return null;
4671
4888
  try {
4672
- const stats = await fs11.lstat(abs);
4889
+ const stats = await fs10.lstat(abs);
4673
4890
  if (!stats.isFile())
4674
4891
  return null;
4675
- return await fs11.readFile(abs, "utf8");
4892
+ return await fs10.readFile(abs, "utf8");
4676
4893
  } catch (err) {
4677
4894
  if (isNodeError(err) && (err.code === "ENOENT" || err.code === "ENOTDIR"))
4678
4895
  return null;
@@ -4723,18 +4940,19 @@ function matchesBundleFilter(entry, filter, contentRoot) {
4723
4940
  }
4724
4941
 
4725
4942
  // dist/branch-workspace.js
4726
- import path18 from "node:path";
4943
+ import fs18 from "node:fs/promises";
4944
+ import path20 from "node:path";
4727
4945
 
4728
4946
  // dist/branch-metadata.js
4729
- import fs15 from "node:fs/promises";
4947
+ import fs14 from "node:fs/promises";
4730
4948
  import path16 from "node:path";
4731
4949
 
4732
4950
  // dist/branch-registry.js
4733
- import fs13 from "node:fs/promises";
4951
+ import fs12 from "node:fs/promises";
4734
4952
  import path14 from "node:path";
4735
4953
 
4736
4954
  // dist/branch-metadata-file.js
4737
- import fs12 from "node:fs/promises";
4955
+ import fs11 from "node:fs/promises";
4738
4956
  import path13 from "node:path";
4739
4957
  var BRANCH_META_DIR = ".canopy-meta";
4740
4958
  var BRANCH_META_FILE = "branch.json";
@@ -4750,7 +4968,7 @@ var branchMetadataFilePath = (branchRoot) => path13.join(path13.resolve(branchRo
4750
4968
  async function readBranchMetadataFile(branchRoot) {
4751
4969
  const resolvedRoot = path13.resolve(branchRoot);
4752
4970
  try {
4753
- const raw = await fs12.readFile(branchMetadataFilePath(resolvedRoot), "utf8");
4971
+ const raw = await fs11.readFile(branchMetadataFilePath(resolvedRoot), "utf8");
4754
4972
  return JSON.parse(raw);
4755
4973
  } catch (err) {
4756
4974
  if (isNotFoundError(err)) {
@@ -4765,7 +4983,7 @@ async function readBranchMetadataFile(branchRoot) {
4765
4983
 
4766
4984
  // dist/branch-registry.js
4767
4985
  init_logger();
4768
- var log3 = createDebugLogger({ prefix: "BranchRegistry" });
4986
+ var log4 = createDebugLogger({ prefix: "BranchRegistry" });
4769
4987
  var REGISTRY_FILE = "branches.json";
4770
4988
  var REGISTRY_STALE_FILE = "branches.stale.json";
4771
4989
  var REGISTRY_TEMP_FILE = "branches.tmp.json";
@@ -4788,7 +5006,7 @@ var BranchRegistry = class {
4788
5006
  async list() {
4789
5007
  let parsed;
4790
5008
  try {
4791
- const raw = await fs13.readFile(this.registryPath, "utf8");
5009
+ const raw = await fs12.readFile(this.registryPath, "utf8");
4792
5010
  parsed = JSON.parse(raw);
4793
5011
  } catch (err) {
4794
5012
  if (isNotFoundError(err)) {
@@ -4845,7 +5063,7 @@ var BranchRegistry = class {
4845
5063
  });
4846
5064
  await this.regenerate();
4847
5065
  } catch (err) {
4848
- log3.warn("invalidate", "Eager regeneration after invalidate() failed", {
5066
+ log4.warn("invalidate", "Eager regeneration after invalidate() failed", {
4849
5067
  error: getErrorMessage(err)
4850
5068
  });
4851
5069
  }
@@ -4870,7 +5088,7 @@ var BranchRegistry = class {
4870
5088
  async performRegenerate() {
4871
5089
  const read = await readResourceGeneration(this.root, RESOURCE2);
4872
5090
  const branches = await this.scanBranchDirectories();
4873
- await fs13.unlink(this.stalePath).catch(() => {
5091
+ await fs12.unlink(this.stalePath).catch(() => {
4874
5092
  });
4875
5093
  if (!read.ok) {
4876
5094
  return branches;
@@ -4881,12 +5099,12 @@ var BranchRegistry = class {
4881
5099
  generation: read.token
4882
5100
  };
4883
5101
  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");
5102
+ await fs12.mkdir(this.root, { recursive: true });
5103
+ await fs12.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
4886
5104
  try {
4887
- await fs13.rename(uniqueTempPath, this.registryPath);
5105
+ await fs12.rename(uniqueTempPath, this.registryPath);
4888
5106
  } catch (err) {
4889
- await fs13.unlink(uniqueTempPath).catch(() => {
5107
+ await fs12.unlink(uniqueTempPath).catch(() => {
4890
5108
  });
4891
5109
  throw err;
4892
5110
  }
@@ -4900,7 +5118,7 @@ var BranchRegistry = class {
4900
5118
  async scanBranchDirectories() {
4901
5119
  const branches = [];
4902
5120
  try {
4903
- const entries = await fs13.readdir(this.root, { withFileTypes: true });
5121
+ const entries = await fs12.readdir(this.root, { withFileTypes: true });
4904
5122
  for (const entry of entries) {
4905
5123
  if (!entry.isDirectory() || entry.name.startsWith(".")) {
4906
5124
  continue;
@@ -4932,11 +5150,11 @@ var BranchRegistry = class {
4932
5150
  };
4933
5151
 
4934
5152
  // dist/utils/occ-json-write.js
4935
- import fs14 from "node:fs/promises";
5153
+ import fs13 from "node:fs/promises";
4936
5154
  import path15 from "node:path";
4937
5155
  import { randomUUID as randomUUID2 } from "node:crypto";
4938
5156
  import lockfile2 from "proper-lockfile";
4939
- var log4 = createDebugLogger({ prefix: "OccJsonWrite" });
5157
+ var log5 = createDebugLogger({ prefix: "OccJsonWrite" });
4940
5158
  var OccWriteConflictError = class extends Error {
4941
5159
  constructor(message = "Concurrent modification detected") {
4942
5160
  super(message);
@@ -4950,28 +5168,28 @@ async function writeOccJsonFile(filePath, payload, options) {
4950
5168
  const data = { ...payload, version: newVersion, writeId };
4951
5169
  const content = JSON.stringify(data, null, 2) + (trailingNewline ? "\n" : "");
4952
5170
  const dir = path15.dirname(filePath);
4953
- await fs14.mkdir(dir, { recursive: true });
5171
+ await fs13.mkdir(dir, { recursive: true });
4954
5172
  const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
4955
- await fs14.writeFile(tempPath, content, "utf-8");
5173
+ await fs13.writeFile(tempPath, content, "utf-8");
4956
5174
  if (expectedVersion === null) {
4957
5175
  try {
4958
- await fs14.link(tempPath, filePath);
5176
+ await fs13.link(tempPath, filePath);
4959
5177
  } catch (err) {
4960
- await fs14.unlink(tempPath).catch(() => {
5178
+ await fs13.unlink(tempPath).catch(() => {
4961
5179
  });
4962
5180
  if (isFileExistsError(err)) {
4963
5181
  throw new OccWriteConflictError();
4964
5182
  }
4965
5183
  throw err;
4966
5184
  }
4967
- await fs14.unlink(tempPath).catch(() => {
5185
+ await fs13.unlink(tempPath).catch(() => {
4968
5186
  });
4969
5187
  return { version: newVersion, writeId };
4970
5188
  }
4971
5189
  try {
4972
5190
  let currentVersion = null;
4973
5191
  try {
4974
- const current = JSON.parse(await fs14.readFile(filePath, "utf-8"));
5192
+ const current = JSON.parse(await fs13.readFile(filePath, "utf-8"));
4975
5193
  currentVersion = current.version ?? 0;
4976
5194
  } catch {
4977
5195
  currentVersion = null;
@@ -4979,16 +5197,16 @@ async function writeOccJsonFile(filePath, payload, options) {
4979
5197
  if (currentVersion !== expectedVersion) {
4980
5198
  throw new OccWriteConflictError();
4981
5199
  }
4982
- await fs14.rename(tempPath, filePath);
5200
+ await fs13.rename(tempPath, filePath);
4983
5201
  if (settleMs > 0) {
4984
5202
  await new Promise((resolve2) => setTimeout(resolve2, settleMs));
4985
5203
  }
4986
- const afterWrite = JSON.parse(await fs14.readFile(filePath, "utf-8"));
5204
+ const afterWrite = JSON.parse(await fs13.readFile(filePath, "utf-8"));
4987
5205
  if (afterWrite.writeId !== writeId) {
4988
5206
  throw new OccWriteConflictError();
4989
5207
  }
4990
5208
  } catch (err) {
4991
- await fs14.unlink(tempPath).catch(() => {
5209
+ await fs13.unlink(tempPath).catch(() => {
4992
5210
  });
4993
5211
  throw err;
4994
5212
  }
@@ -5014,8 +5232,13 @@ async function withOccRetry(operation, options) {
5014
5232
  throw new Error("Unreachable");
5015
5233
  }
5016
5234
  async function withOccFileLock(filePath, fn) {
5017
- const dir = path15.dirname(filePath);
5018
- await fs14.mkdir(dir, { recursive: true });
5235
+ try {
5236
+ await fs13.mkdir(path15.dirname(filePath));
5237
+ } catch (err) {
5238
+ if (!isNodeError(err) || err.code !== "EEXIST") {
5239
+ throw new OccWriteConflictError(`Could not acquire file lock: ${getErrorMessage(err)}`);
5240
+ }
5241
+ }
5019
5242
  const MAX_ATTEMPTS = 14;
5020
5243
  let release = null;
5021
5244
  for (let attempt = 1; release === null; attempt++) {
@@ -5031,7 +5254,7 @@ async function withOccFileLock(filePath, fn) {
5031
5254
  // Our critical sections are short and idempotent-on-conflict (OCC
5032
5255
  // verify inside); log and let the section finish.
5033
5256
  onCompromised: (err) => {
5034
- log4.warn("lock", `Lock compromised mid-hold for ${filePath}`, {
5257
+ log5.warn("lock", `Lock compromised mid-hold for ${filePath}`, {
5035
5258
  error: getErrorMessage(err)
5036
5259
  });
5037
5260
  }
@@ -5053,7 +5276,7 @@ async function withOccFileLock(filePath, fn) {
5053
5276
  await release().catch((err) => {
5054
5277
  if (isNodeError(err) && err.code === "ENOENT")
5055
5278
  return;
5056
- log4.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage(err) });
5279
+ log5.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage(err) });
5057
5280
  });
5058
5281
  }
5059
5282
  }
@@ -5086,7 +5309,7 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
5086
5309
  }
5087
5310
  async load() {
5088
5311
  try {
5089
- const raw = await fs15.readFile(this.filePath, "utf8");
5312
+ const raw = await fs14.readFile(this.filePath, "utf8");
5090
5313
  const parsed = JSON.parse(raw);
5091
5314
  const version = parsed.version ?? 0;
5092
5315
  return { meta: parsed, version };
@@ -5132,15 +5355,13 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
5132
5355
  * entry with no clone behind it. It runs BEFORE the lock stack so a doomed
5133
5356
  * save fails fast instead of paying for a lock.
5134
5357
  *
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".
5358
+ * A save that passes the check just before a delete still cannot write: the
5359
+ * delete renames the tree away under the same lockfile, and that lockfile
5360
+ * creates only its own directory (`withOccFileLock`), so the save fails there.
5140
5361
  */
5141
5362
  async save(incoming) {
5142
5363
  try {
5143
- await fs15.stat(this.branchRoot);
5364
+ await fs14.stat(this.branchRoot);
5144
5365
  } catch (err) {
5145
5366
  if (isNotFoundError(err)) {
5146
5367
  throw new BranchMetadataConflictError("Branch no longer exists");
@@ -5151,37 +5372,7 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
5151
5372
  try {
5152
5373
  saved = await withLock(this.filePath, () => withOccFileLock(this.filePath, () => withOccRetry(async () => {
5153
5374
  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
- };
5375
+ const merged = mergeBranchMetadata(existing, version, incoming);
5185
5376
  const written = await this.write(merged, version);
5186
5377
  merged.version = written.version;
5187
5378
  merged.writeId = written.writeId;
@@ -5202,9 +5393,40 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
5202
5393
  await registry2.invalidate();
5203
5394
  }
5204
5395
  };
5205
- var getBranchMetadataFileManager = (branchRoot, baseRoot, options) => {
5206
- return BranchMetadataFileManager.get(branchRoot, baseRoot, options);
5207
- };
5396
+ function mergeBranchMetadata(existing, version, incoming, now = (/* @__PURE__ */ new Date()).toISOString()) {
5397
+ const defaults = {
5398
+ name: "unknown",
5399
+ status: "editing",
5400
+ access: {},
5401
+ createdBy: "unknown",
5402
+ createdAt: now,
5403
+ updatedAt: now
5404
+ };
5405
+ return {
5406
+ schemaVersion: CURRENT_SCHEMA_VERSION,
5407
+ version: version ?? 0,
5408
+ branch: {
5409
+ ...defaults,
5410
+ ...existing?.branch,
5411
+ ...incoming.branch,
5412
+ access: {
5413
+ ...existing?.branch?.access,
5414
+ ...incoming.branch?.access
5415
+ },
5416
+ // Immutable after creation
5417
+ createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
5418
+ createdAt: existing?.branch.createdAt ?? defaults.createdAt,
5419
+ // Fork point is recorded once at creation; later saves must not move it
5420
+ baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch,
5421
+ // Always stamped fresh; the spreads above would otherwise let the creation-time value win
5422
+ // forever, freezing the timestamp the editor's Branches panel sorts and displays by
5423
+ updatedAt: now
5424
+ }
5425
+ };
5426
+ }
5427
+ function buildInitialBranchMetadata(incoming, now) {
5428
+ return mergeBranchMetadata(null, null, incoming, now);
5429
+ }
5208
5430
  var loadBranchContext = async (options) => {
5209
5431
  const { branchRoot, baseRoot } = resolveBranchPath({
5210
5432
  branchName: options.branchName,
@@ -5222,58 +5444,17 @@ var loadBranchContext = async (options) => {
5222
5444
  };
5223
5445
  };
5224
5446
 
5225
- // dist/branch-workspace.js
5226
- init_operating_mode();
5447
+ // dist/branch-provisioning.js
5448
+ import { createHash, randomBytes } from "node:crypto";
5449
+ import fs16 from "node:fs/promises";
5450
+ import path18 from "node:path";
5227
5451
 
5228
5452
  // dist/git-manager.js
5229
- import fs16 from "node:fs/promises";
5453
+ import fs15 from "node:fs/promises";
5230
5454
  import path17 from "node:path";
5231
5455
  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;
5264
- }
5265
- }
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" });
5456
+ init_logger();
5457
+ var log6 = createDebugLogger({ prefix: "GitManager" });
5277
5458
  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
5459
  var FORCE_C_LOCALE = { LC_ALL: "C", LANG: "C" };
5279
5460
  function gitChildEnv(overrides) {
@@ -5284,27 +5465,119 @@ function gitChildEnv(overrides) {
5284
5465
  }
5285
5466
  return { ...env, ...FORCE_C_LOCALE, ...overrides };
5286
5467
  }
5468
+ 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)$/;
5469
+ function gitNetworkChildEnv() {
5470
+ const env = {};
5471
+ for (const [key, value] of Object.entries(process.env)) {
5472
+ if (value === void 0)
5473
+ continue;
5474
+ if (GIT_ENV_PASSTHROUGH.test(key) || GIT_NETWORK_ENV_PASSTHROUGH.test(key))
5475
+ env[key] = value;
5476
+ }
5477
+ return { ...env, ...FORCE_C_LOCALE };
5478
+ }
5479
+ var NO_AUTO_GC_CONFIG = ["gc.auto=0", "maintenance.auto=false"];
5480
+ var REMOTE_GIT_CONFIG = [
5481
+ ["gc.auto", "0"],
5482
+ ["receive.autogc", "false"],
5483
+ ["maintenance.auto", "false"],
5484
+ ["transfer.unpackLimit", "1"]
5485
+ ];
5486
+ async function ensureRemoteGitConfig(gitDir) {
5487
+ const git = simpleGit2().env(gitChildEnv({}));
5488
+ const current = /* @__PURE__ */ new Map();
5489
+ const listed = await git.raw(["--git-dir", gitDir, "config", "--local", "--list"]);
5490
+ for (const line of listed.split("\n")) {
5491
+ const eq = line.indexOf("=");
5492
+ if (eq > 0)
5493
+ current.set(line.slice(0, eq).toLowerCase(), line.slice(eq + 1));
5494
+ }
5495
+ for (const [key, value] of REMOTE_GIT_CONFIG) {
5496
+ if (current.get(key.toLowerCase()) === value)
5497
+ continue;
5498
+ await git.raw(["--git-dir", gitDir, "config", "--local", key, value]);
5499
+ }
5500
+ }
5501
+ var REMOTE_GIT_MAX_LOOSE_OBJECTS = 50;
5502
+ var REMOTE_GIT_MAX_PACKS = 6;
5503
+ async function countObjects(git, gitDir) {
5504
+ const output = await git.raw(["--git-dir", gitDir, "count-objects", "-v"]);
5505
+ const fields = new Map(output.split("\n").map((line) => {
5506
+ const [name, value = ""] = line.split(": ");
5507
+ return [name, value.trim()];
5508
+ }));
5509
+ const field = (name) => {
5510
+ const value = fields.get(name);
5511
+ if (!value || !/^\d+$/.test(value)) {
5512
+ throw new Error(`count-objects -v printed no '${name}' count: ${output.trim()}`);
5513
+ }
5514
+ return Number(value);
5515
+ };
5516
+ return { loose: field("count"), packs: field("packs") };
5517
+ }
5518
+ async function repackBareRemoteIfNeeded(gitDir) {
5519
+ const git = simpleGit2().env(gitChildEnv({}));
5520
+ const before = await countObjects(git, gitDir);
5521
+ if (before.loose <= REMOTE_GIT_MAX_LOOSE_OBJECTS && before.packs <= REMOTE_GIT_MAX_PACKS) {
5522
+ return { repacked: false, before };
5523
+ }
5524
+ const startedAt = Date.now();
5525
+ await git.raw(["--git-dir", gitDir, "repack", "-a", "-d", "--cruft", "-q"]);
5526
+ await git.raw(["--git-dir", gitDir, "pack-refs", "--all"]);
5527
+ const after = await countObjects(git, gitDir);
5528
+ return { repacked: true, before, after, ms: Date.now() - startedAt };
5529
+ }
5530
+ function failOnSignalExit(error, result) {
5531
+ if (error)
5532
+ return error;
5533
+ if (result.exitCode === null) {
5534
+ return new Error("git was killed by a signal before it finished");
5535
+ }
5536
+ return void 0;
5537
+ }
5538
+ var PARALLEL_CHECKOUT_ARGS = [
5539
+ "-c",
5540
+ "checkout.workers=8",
5541
+ "-c",
5542
+ "checkout.thresholdForParallelism=1"
5543
+ ];
5544
+ async function dirState(dir) {
5545
+ try {
5546
+ return (await fs15.readdir(dir)).length === 0 ? "empty" : "occupied";
5547
+ } catch (err) {
5548
+ if (isNodeError(err) && err.code === "ENOENT")
5549
+ return "absent";
5550
+ throw err;
5551
+ }
5552
+ }
5553
+ function managedWorkspaceConfig(gitBotAuthorName, gitBotAuthorEmail) {
5554
+ return {
5555
+ "canopycms.managed": "true",
5556
+ "user.name": gitBotAuthorName,
5557
+ "user.email": gitBotAuthorEmail
5558
+ };
5559
+ }
5287
5560
  var remoteInitLocks = /* @__PURE__ */ new Map();
5288
5561
  var GITHUB_TRACKING_REF_PREFIX = "refs/remotes/github/";
5289
5562
  async function ensureGitExcludePattern(repoPath, pattern) {
5290
5563
  const excludePath = path17.join(repoPath, ".git", "info", "exclude");
5291
- await fs16.mkdir(path17.dirname(excludePath), { recursive: true });
5564
+ await fs15.mkdir(path17.dirname(excludePath), { recursive: true });
5292
5565
  let content = "";
5293
5566
  try {
5294
- content = await fs16.readFile(excludePath, "utf-8");
5567
+ content = await fs15.readFile(excludePath, "utf-8");
5295
5568
  } catch (err) {
5296
5569
  if (!isNotFoundError(err))
5297
5570
  throw err;
5298
5571
  }
5299
5572
  const lines = content.split("\n");
5300
5573
  if (lines.some((line) => line.trim() === pattern)) {
5301
- log5.debug("git", "Pattern already in .git/info/exclude", { pattern });
5574
+ log6.debug("git", "Pattern already in .git/info/exclude", { pattern });
5302
5575
  return;
5303
5576
  }
5304
5577
  const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
5305
5578
  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 });
5579
+ await fs15.writeFile(excludePath, newContent, "utf-8");
5580
+ log6.debug("git", "Added pattern to .git/info/exclude", { pattern });
5308
5581
  }
5309
5582
  var GitConflictError = class extends Error {
5310
5583
  constructor(conflictedFiles) {
@@ -5322,24 +5595,95 @@ var GitRemoteRefMissingError = class extends Error {
5322
5595
  this.name = "GitRemoteRefMissingError";
5323
5596
  }
5324
5597
  };
5598
+ var RemoteNotReadyError = class extends Error {
5599
+ constructor(expectedRemotePath) {
5600
+ 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.`);
5601
+ this.expectedRemotePath = expectedRemotePath;
5602
+ this.name = "RemoteNotReadyError";
5603
+ }
5604
+ };
5605
+ var SettingsRemoteUnreadableError = class extends Error {
5606
+ constructor(branch, remote, gitError) {
5607
+ super(`CanopyCMS: could not read settings branch '${branch}' from remote '${remote}', so the settings workspace was not provisioned: ${getErrorMessage(gitError)}`);
5608
+ this.branch = branch;
5609
+ this.remote = remote;
5610
+ this.gitError = gitError;
5611
+ this.name = "SettingsRemoteUnreadableError";
5612
+ }
5613
+ };
5614
+ var SettingsBranchDivergedError = class extends Error {
5615
+ constructor(branch, workspacePath, localWork) {
5616
+ 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.`);
5617
+ this.branch = branch;
5618
+ this.workspacePath = workspacePath;
5619
+ this.localWork = localWork;
5620
+ this.name = "SettingsBranchDivergedError";
5621
+ }
5622
+ };
5623
+ var SETTINGS_BRANCH_FILES = /* @__PURE__ */ new Set(["permissions.json", "groups.json"]);
5624
+ var SettingsBranchHasContentHistoryError = class extends Error {
5625
+ constructor(branch, remote, evidence) {
5626
+ 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.`);
5627
+ this.branch = branch;
5628
+ this.remote = remote;
5629
+ this.evidence = evidence;
5630
+ this.name = "SettingsBranchHasContentHistoryError";
5631
+ }
5632
+ };
5325
5633
  var GitManager = class _GitManager {
5326
5634
  constructor(options, gitOptions) {
5327
5635
  this.repoPath = path17.resolve(options.repoPath);
5328
5636
  this.baseBranch = options.baseBranch ?? "main";
5329
5637
  this.remote = options.remote ?? "origin";
5330
5638
  this.skipIndexMarker = options.skipIndexMarker ?? false;
5331
- this.git = simpleGit2({ baseDir: this.repoPath, ...gitOptions });
5639
+ this.git = simpleGit2({
5640
+ baseDir: this.repoPath,
5641
+ errors: failOnSignalExit,
5642
+ ...gitOptions,
5643
+ config: [...NO_AUTO_GC_CONFIG, ...gitOptions?.config ?? []]
5644
+ });
5332
5645
  this.git.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path17.dirname(this.repoPath) }));
5333
5646
  }
5334
- static async cloneRepo(remoteUrl, targetPath, baseBranch = "main") {
5335
- log5.debug("git", "Cloning repository", {
5647
+ /**
5648
+ * Single-branch clone of `baseBranch`, retried once: a clone can lose a race
5649
+ * with the worker's repack of `remote.git` deleting a pack it had just
5650
+ * listed. Before the retry it clears only what it created itself, so a
5651
+ * target that held files beforehand is never touched.
5652
+ */
5653
+ static async cloneRepo(remoteUrl, targetPath, baseBranch = "main", options = {}) {
5654
+ log6.debug("git", "Cloning repository", {
5336
5655
  remoteUrl,
5337
5656
  targetPath,
5338
5657
  baseBranch
5339
5658
  });
5340
- const git = simpleGit2();
5341
- await git.clone(remoteUrl, targetPath, ["--branch", baseBranch, "--single-branch"]);
5342
- log5.debug("git", "Clone complete");
5659
+ const args = ["--single-branch", "--branch", baseBranch];
5660
+ if (options.noCheckout)
5661
+ args.push("--no-checkout");
5662
+ if (options.remoteName)
5663
+ args.push("--origin", options.remoteName);
5664
+ for (const [key, value] of Object.entries(options.config ?? {})) {
5665
+ args.push("-c", `${key}=${value}`);
5666
+ }
5667
+ for (const setting of NO_AUTO_GC_CONFIG)
5668
+ args.push("-c", setting);
5669
+ const git = simpleGit2({ config: [...NO_AUTO_GC_CONFIG], errors: failOnSignalExit });
5670
+ git.env({
5671
+ ...isNetworkRemoteUrl(remoteUrl) ? gitNetworkChildEnv() : gitChildEnv({}),
5672
+ GIT_CEILING_DIRECTORIES: path17.dirname(targetPath)
5673
+ });
5674
+ const before = await dirState(targetPath);
5675
+ try {
5676
+ await git.clone(remoteUrl, targetPath, args);
5677
+ } catch (err) {
5678
+ if (before === "occupied")
5679
+ throw err;
5680
+ canopyLogWarn(`[canopy] clone into ${targetPath} failed, retrying once: ` + redactCredentials(getErrorMessage(err)));
5681
+ await fs15.rm(targetPath, { recursive: true, force: true });
5682
+ if (before === "empty")
5683
+ await fs15.mkdir(targetPath);
5684
+ await git.clone(remoteUrl, targetPath, args);
5685
+ }
5686
+ log6.debug("git", "Clone complete");
5343
5687
  }
5344
5688
  /**
5345
5689
  * Initializes a local bare git repository to simulate a remote for dev mode,
@@ -5353,15 +5697,15 @@ var GitManager = class _GitManager {
5353
5697
  static async ensureLocalSimulatedRemote(options) {
5354
5698
  const existingLock = remoteInitLocks.get(options.remotePath);
5355
5699
  if (existingLock) {
5356
- log5.debug("git", "Waiting for existing remote initialization", {
5700
+ log6.debug("git", "Waiting for existing remote initialization", {
5357
5701
  remotePath: options.remotePath
5358
5702
  });
5359
5703
  await existingLock;
5360
5704
  }
5361
- const lockPromise = log5.timed("git", "ensureLocalSimulatedRemote", async () => {
5705
+ const lockPromise = log6.timed("git", "ensureLocalSimulatedRemote", async () => {
5362
5706
  let releaseLock;
5363
5707
  try {
5364
- log5.debug("git", "Initializing local simulated remote", {
5708
+ log6.debug("git", "Initializing local simulated remote", {
5365
5709
  remotePath: options.remotePath,
5366
5710
  baseBranch: options.baseBranch
5367
5711
  });
@@ -5369,14 +5713,26 @@ var GitManager = class _GitManager {
5369
5713
  releaseLock = await acquireProvisioningLock(remoteParent, ".remote-init.lock");
5370
5714
  let remoteExists = false;
5371
5715
  try {
5372
- const stat = await fs16.stat(options.remotePath);
5716
+ const stat = await fs15.stat(options.remotePath);
5373
5717
  remoteExists = stat.isDirectory();
5374
5718
  } catch (err) {
5375
5719
  if (!isNotFoundError(err))
5376
5720
  throw err;
5377
5721
  }
5722
+ if (remoteExists) {
5723
+ try {
5724
+ const repack = await repackBareRemoteIfNeeded(options.remotePath);
5725
+ if (repack.repacked)
5726
+ log6.debug("git", "Repacked local simulated remote", repack);
5727
+ } catch (err) {
5728
+ log6.debug("git", "Could not repack local simulated remote", {
5729
+ error: getErrorMessage(err)
5730
+ });
5731
+ }
5732
+ }
5378
5733
  if (remoteExists && await _GitManager.bareRemoteHasBranch(options.remotePath, options.baseBranch)) {
5379
- log5.debug("git", "Remote already has base branch, skipping");
5734
+ log6.debug("git", "Remote already has base branch, skipping");
5735
+ await ensureRemoteGitConfig(options.remotePath);
5380
5736
  return;
5381
5737
  }
5382
5738
  let gitRoot = options.sourcePath;
@@ -5408,12 +5764,12 @@ var GitManager = class _GitManager {
5408
5764
  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
5765
  }
5410
5766
  if (remoteExists) {
5411
- log5.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
5767
+ log6.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
5412
5768
  remotePath: options.remotePath,
5413
5769
  baseBranch: options.baseBranch
5414
5770
  });
5415
5771
  } else {
5416
- log5.debug("git", "Creating bare remote repository");
5772
+ log6.debug("git", "Creating bare remote repository");
5417
5773
  await simpleGit2().raw([
5418
5774
  "init",
5419
5775
  "--bare",
@@ -5421,19 +5777,20 @@ var GitManager = class _GitManager {
5421
5777
  options.remotePath
5422
5778
  ]);
5423
5779
  }
5780
+ await ensureRemoteGitConfig(options.remotePath);
5424
5781
  await _GitManager.pushBranchToLocalRemote({
5425
5782
  sourceGit,
5426
5783
  remotePath: options.remotePath,
5427
5784
  baseBranch: options.baseBranch,
5428
5785
  subdirectory: options.subdirectory
5429
5786
  });
5430
- log5.debug("git", "Remote initialization complete");
5787
+ log6.debug("git", "Remote initialization complete");
5431
5788
  } finally {
5432
5789
  if (releaseLock) {
5433
5790
  try {
5434
5791
  await releaseLock();
5435
5792
  } catch (err) {
5436
- log5.debug("git", "Failed to release remote-init lock", { err });
5793
+ log6.debug("git", "Failed to release remote-init lock", { err });
5437
5794
  }
5438
5795
  }
5439
5796
  remoteInitLocks.delete(options.remotePath);
@@ -5501,7 +5858,7 @@ var GitManager = class _GitManager {
5501
5858
  * deleteBranchHandler: a head left in `remote.git` forever makes the
5502
5859
  * create -> publish -> squash-merge -> delete -> reuse-the-name cycle reject
5503
5860
  * 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
5861
+ * (`GitManager.push()` pushes the branch to the same name, and a squash-merged old tip is
5505
5862
  * not an ancestor of the new branch), and a retried submit then skips the
5506
5863
  * local push on a clean tree and enqueues the worker push of the STALE head,
5507
5864
  * resurrecting the deleted branch's content on GitHub as an apparent success.
@@ -5601,7 +5958,7 @@ var GitManager = class _GitManager {
5601
5958
  */
5602
5959
  static async validateGitRepoExists(repoPath) {
5603
5960
  try {
5604
- const stat = await fs16.stat(path17.join(repoPath, ".git"));
5961
+ const stat = await fs15.stat(path17.join(repoPath, ".git"));
5605
5962
  if (!stat.isDirectory()) {
5606
5963
  throw new Error(`Expected git repo at ${repoPath}`);
5607
5964
  }
@@ -5632,6 +5989,12 @@ var GitManager = class _GitManager {
5632
5989
  return;
5633
5990
  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
5991
  }
5992
+ /** The not-ready error for a mode whose remote is auto-detected, or `undefined` for other modes. */
5993
+ static async remoteNotReadyError(mode) {
5994
+ const { operatingStrategy: operatingStrategy2 } = await Promise.resolve().then(() => (init_operating_mode(), operating_mode_exports));
5995
+ const { autoDetectRemotePath } = operatingStrategy2(mode).getRemoteUrlConfig();
5996
+ return autoDetectRemotePath ? new RemoteNotReadyError(autoDetectRemotePath) : void 0;
5997
+ }
5635
5998
  /**
5636
5999
  * Resolves the remote URL for git operations following the priority:
5637
6000
  * 1. Explicit remoteUrl parameter
@@ -5666,9 +6029,9 @@ var GitManager = class _GitManager {
5666
6029
  }
5667
6030
  if (config.autoDetectRemotePath) {
5668
6031
  try {
5669
- const stat = await fs16.stat(config.autoDetectRemotePath);
6032
+ const stat = await fs15.stat(config.autoDetectRemotePath);
5670
6033
  if (stat.isDirectory()) {
5671
- log5.debug("git", "Auto-detected local remote", {
6034
+ log6.debug("git", "Auto-detected local remote", {
5672
6035
  path: config.autoDetectRemotePath
5673
6036
  });
5674
6037
  return config.autoDetectRemotePath;
@@ -5691,6 +6054,31 @@ var GitManager = class _GitManager {
5691
6054
  }
5692
6055
  return void 0;
5693
6056
  }
6057
+ /**
6058
+ * {@link resolveRemoteUrl} for a workspace about to be cloned, which needs a
6059
+ * remote: none resolving is the worker-not-ready error in a mode whose
6060
+ * remote the worker creates, and a configuration error otherwise.
6061
+ */
6062
+ static async resolveCloneRemoteUrl(options) {
6063
+ const remoteUrl = await _GitManager.resolveRemoteUrl(options);
6064
+ if (remoteUrl)
6065
+ return remoteUrl;
6066
+ const notReady = await _GitManager.remoteNotReadyError(options.mode);
6067
+ if (notReady)
6068
+ throw notReady;
6069
+ throw new Error("CanopyCMS: defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is required to initialize workspace");
6070
+ }
6071
+ /**
6072
+ * {@link cloneRepo} for a workspace, failing with one message that names the
6073
+ * workspace, the remote and the base branch, which git's own error mixes.
6074
+ */
6075
+ static async cloneWorkspace(remoteUrl, workspacePath, baseBranch, options) {
6076
+ try {
6077
+ await _GitManager.cloneRepo(remoteUrl, workspacePath, baseBranch, options);
6078
+ } catch (err) {
6079
+ throw new Error(`Failed to clone branch workspace at ${workspacePath} from ${remoteUrl} (base branch '${baseBranch}'): ${getErrorMessage(err)}`);
6080
+ }
6081
+ }
5694
6082
  /**
5695
6083
  * Check whether a git repository is already initialized at `workspacePath`.
5696
6084
  *
@@ -5730,21 +6118,24 @@ var GitManager = class _GitManager {
5730
6118
  if (!repoExists) {
5731
6119
  const gitPath = path17.join(options.workspacePath, ".git");
5732
6120
  try {
5733
- const stat = await fs16.stat(gitPath);
6121
+ const stat = await fs15.stat(gitPath);
5734
6122
  if (stat.isDirectory()) {
5735
- log5.debug("git", "Removing corrupt .git directory", {
6123
+ log6.debug("git", "Removing corrupt .git directory", {
5736
6124
  workspacePath: options.workspacePath
5737
6125
  });
5738
- await fs16.rm(gitPath, { recursive: true });
6126
+ await fs15.rm(gitPath, { recursive: true });
5739
6127
  }
5740
6128
  } catch (cleanupErr) {
5741
6129
  if (!isNotFoundError(cleanupErr))
5742
6130
  throw cleanupErr;
5743
6131
  }
5744
6132
  }
6133
+ const step = (name, run) => options.provisionLog ? options.provisionLog.step(name, run) : run();
6134
+ const identity = managedWorkspaceConfig(options.gitBotAuthorName, options.gitBotAuthorEmail);
5745
6135
  let justCloned = false;
6136
+ let resolvedRemoteUrl;
5746
6137
  if (!repoExists) {
5747
- const remoteUrl = await _GitManager.resolveRemoteUrl({
6138
+ const remoteUrl = await _GitManager.resolveCloneRemoteUrl({
5748
6139
  mode: options.mode,
5749
6140
  remoteUrl: options.remoteUrl,
5750
6141
  defaultRemoteUrl: options.defaultRemoteUrl,
@@ -5752,20 +6143,13 @@ var GitManager = class _GitManager {
5752
6143
  sourceRoot: options.sourceRoot,
5753
6144
  allowNetworkRemoteInProd: options.allowNetworkRemoteInProd
5754
6145
  });
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
- }
6146
+ await step("clone", () => _GitManager.cloneWorkspace(remoteUrl, options.workspacePath, baseBranch, {
6147
+ remoteName,
6148
+ noCheckout: options.branchType === "content",
6149
+ config: identity
6150
+ }));
5763
6151
  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);
6152
+ resolvedRemoteUrl = remoteUrl;
5769
6153
  }
5770
6154
  const git = new _GitManager({
5771
6155
  repoPath: options.workspacePath,
@@ -5773,32 +6157,38 @@ var GitManager = class _GitManager {
5773
6157
  remote: remoteName,
5774
6158
  skipIndexMarker: options.branchType === "orphan"
5775
6159
  });
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
6160
  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
6161
+ await step("config", async () => {
6162
+ await git.ensureLocalConfig(identity);
6163
+ const remoteUrl = await _GitManager.resolveRemoteUrl({
6164
+ mode: options.mode,
6165
+ remoteUrl: options.remoteUrl,
6166
+ defaultRemoteUrl: options.defaultRemoteUrl,
6167
+ baseBranch,
6168
+ sourceRoot: options.sourceRoot,
6169
+ allowNetworkRemoteInProd: options.allowNetworkRemoteInProd
6170
+ });
6171
+ if (remoteUrl) {
6172
+ await git.ensureRemote(remoteUrl);
6173
+ }
6174
+ resolvedRemoteUrl = remoteUrl;
5790
6175
  });
5791
- if (remoteUrl) {
5792
- await git.ensureRemote(remoteUrl);
5793
- }
5794
6176
  }
5795
6177
  if (options.branchType === "orphan") {
5796
- await git.createOrphanSettingsBranch(options.branchName, {});
5797
- await git.ensureGitExclude("*.lock");
6178
+ try {
6179
+ await step("checkout", () => git.createOrphanSettingsBranch(options.branchName, {}));
6180
+ } catch (err) {
6181
+ if (err instanceof SettingsRemoteUnreadableError && !resolvedRemoteUrl) {
6182
+ throw await _GitManager.remoteNotReadyError(options.mode) ?? err;
6183
+ }
6184
+ throw err;
6185
+ }
6186
+ await step("exclude", () => git.ensureGitExclude("*.lock"));
5798
6187
  } else {
5799
- await git.checkoutBranch(options.branchName);
5800
- if (options.gitExcludePattern) {
5801
- await git.ensureGitExclude(options.gitExcludePattern);
6188
+ await step("checkout", () => justCloned ? git.checkoutFreshClone(options.branchName) : git.checkoutBranch(options.branchName));
6189
+ const pattern = options.gitExcludePattern;
6190
+ if (pattern) {
6191
+ await step("exclude", () => git.ensureGitExclude(pattern));
5802
6192
  }
5803
6193
  }
5804
6194
  return git;
@@ -5840,6 +6230,21 @@ var GitManager = class _GitManager {
5840
6230
  await this.invalidateContentIndexes();
5841
6231
  }
5842
6232
  }
6233
+ /**
6234
+ * The one checkout of a `--no-checkout` clone, which has the base branch
6235
+ * and its remote-tracking ref and nothing else, so neither the branch
6236
+ * listing nor the fetch that {@link checkoutBranch} starts with is needed.
6237
+ * The missing index makes git populate the whole tree, even for the base
6238
+ * branch HEAD already names.
6239
+ */
6240
+ async checkoutFreshClone(branch) {
6241
+ const target = branch === this.baseBranch ? ["checkout", branch] : ["checkout", "-b", branch, `${this.remote}/${this.baseBranch}`];
6242
+ try {
6243
+ await this.git.raw([...PARALLEL_CHECKOUT_ARGS, ...target]);
6244
+ } finally {
6245
+ await this.invalidateContentIndexes();
6246
+ }
6247
+ }
5843
6248
  async checkoutBranchInner(branch) {
5844
6249
  const branches = await this.git.branch();
5845
6250
  if (branches.all.includes(branch)) {
@@ -5902,7 +6307,7 @@ var GitManager = class _GitManager {
5902
6307
  const branches = await this.git.branch();
5903
6308
  const currentBranch = branches.current;
5904
6309
  try {
5905
- await this.git.fetch(this.remote, currentBranch);
6310
+ await this.git.fetch(this.remote, `refs/heads/${currentBranch}`);
5906
6311
  } catch (err) {
5907
6312
  if (!isMissingRemoteRefFailure(getErrorMessage(err)))
5908
6313
  throw err;
@@ -5955,23 +6360,46 @@ var GitManager = class _GitManager {
5955
6360
  throw err;
5956
6361
  }
5957
6362
  }
6363
+ /** `--sparse` stages a named path outside a sparse clone's cone (branch-sparse.ts). */
5958
6364
  async add(files) {
5959
6365
  const fileArray = Array.isArray(files) ? files : [files];
5960
- await this.git.add(fileArray);
6366
+ await this.git.raw(["add", "--sparse", "--", ...fileArray]);
6367
+ }
6368
+ /**
6369
+ * Restrict the working tree to `dirs` in cone mode, which always keeps the root-level files.
6370
+ * Runs before a `--no-checkout` clone's one checkout, which invalidates the content caches.
6371
+ */
6372
+ async setSparseCone(dirs) {
6373
+ await this.git.raw(["sparse-checkout", "set", "--cone", "--", ...dirs]);
6374
+ }
6375
+ /** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
6376
+ async addAllExceptCanopyState() {
6377
+ await stageAllExceptCanopyState(this.git);
5961
6378
  }
5962
6379
  async commit(message) {
5963
6380
  await this.git.commit(message);
5964
6381
  }
5965
6382
  async push(branch) {
5966
- const target = branch ?? await this.git.revparse(["--abbrev-ref", "HEAD"]);
6383
+ const target = branch ?? await this.currentBranchName();
5967
6384
  await this.git.raw([
5968
6385
  "push",
5969
6386
  "--set-upstream",
5970
6387
  "--end-of-options",
5971
6388
  this.remote,
5972
- `${target}:${target}`
6389
+ `refs/heads/${target}:refs/heads/${target}`
5973
6390
  ]);
5974
6391
  }
6392
+ /**
6393
+ * The checked-out branch's plain name. `rev-parse --abbrev-ref HEAD` prints
6394
+ * `heads/<name>` when a same-named tag exists, which is not a branch name.
6395
+ */
6396
+ async currentBranchName() {
6397
+ const branches = await this.git.branch();
6398
+ if (branches.detached || !branches.current) {
6399
+ throw new Error(`CanopyCMS: no branch is checked out (detached HEAD) in ${this.repoPath}`);
6400
+ }
6401
+ return branches.current;
6402
+ }
5975
6403
  /**
5976
6404
  * Whether the local branch has commits the remote mirror doesn't -- i.e.
5977
6405
  * whether push() would move the remote ref forward.
@@ -5990,12 +6418,12 @@ var GitManager = class _GitManager {
5990
6418
  * ("couldn't find remote ref"); that counts as "ahead", not an error.
5991
6419
  */
5992
6420
  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();
6421
+ const target = branch ?? await this.git.revparse(["--verify", "--abbrev-ref", "--end-of-options", "HEAD"]);
6422
+ const localSha = (await this.git.revparse(["--verify", "--end-of-options", target])).trim();
5995
6423
  let fetchedTip;
5996
6424
  try {
5997
6425
  await this.git.raw(["fetch", "--end-of-options", this.remote, target]);
5998
- fetchedTip = (await this.git.revparse(["--end-of-options", "FETCH_HEAD"])).trim();
6426
+ fetchedTip = (await this.git.revparse(["--verify", "--end-of-options", "FETCH_HEAD"])).trim();
5999
6427
  } catch {
6000
6428
  return true;
6001
6429
  }
@@ -6004,6 +6432,25 @@ var GitManager = class _GitManager {
6004
6432
  const aheadCount = (await this.git.raw(["rev-list", "--count", `${fetchedTip}..${localSha}`])).trim();
6005
6433
  return aheadCount !== "0";
6006
6434
  }
6435
+ /**
6436
+ * Repo-relative paths that differ between HEAD and its merge base with the
6437
+ * base branch: everything the branch changes, across all of its commits.
6438
+ * Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
6439
+ * constraint.
6440
+ */
6441
+ async listChangedPathsSinceBase() {
6442
+ await this.git.raw(["fetch", "--end-of-options", this.remote, this.baseBranch]);
6443
+ const baseTip = (await this.git.revparse(["--verify", "--end-of-options", "FETCH_HEAD"])).trim();
6444
+ const output = await this.git.raw([
6445
+ "diff",
6446
+ "--name-only",
6447
+ "-z",
6448
+ "--no-renames",
6449
+ `${baseTip}...HEAD`,
6450
+ "--"
6451
+ ]);
6452
+ return output.split("\0").filter((p) => p.length > 0);
6453
+ }
6007
6454
  async ensureAuthor(author) {
6008
6455
  const config = await this.git.listConfig();
6009
6456
  const isManaged = config.all["canopycms.managed"] === "true";
@@ -6019,6 +6466,14 @@ var GitManager = class _GitManager {
6019
6466
  await this.git.addConfig("user.email", author.email);
6020
6467
  }
6021
6468
  }
6469
+ /** Writes each key into this repo's own config only where it differs. */
6470
+ async ensureLocalConfig(values) {
6471
+ const local = await this.git.listConfig("local");
6472
+ for (const [key, value] of Object.entries(values)) {
6473
+ if (local.all[key] !== value)
6474
+ await this.git.addConfig(key, value);
6475
+ }
6476
+ }
6022
6477
  async ensureRemote(remoteUrl) {
6023
6478
  const config = await this.git.listConfig();
6024
6479
  const isManaged = config.all["canopycms.managed"] === "true";
@@ -6057,10 +6512,24 @@ var GitManager = class _GitManager {
6057
6512
  await ensureGitExcludePattern(this.repoPath, pattern);
6058
6513
  }
6059
6514
  /**
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).
6515
+ * Put the workspace on the settings branch: an orphan (no shared history),
6516
+ * so deployment-specific settings never pollute content history. It holds
6517
+ * only settings files committed by explicit path (permissions.json,
6518
+ * groups.json at the workspace root).
6519
+ *
6520
+ * The remote is the settings branch's durable copy, and the workspace clone
6521
+ * is `--single-branch` at the base branch, so the remote is asked first:
6522
+ * - no local branch: check out the remote's, or create an empty orphan only
6523
+ * when the remote has none. An unreadable remote throws
6524
+ * {@link SettingsRemoteUnreadableError} rather than guess "none".
6525
+ * - local branch present: a history unrelated to the remote's is repaired
6526
+ * onto the remote's when the local branch is still its empty initial
6527
+ * commit with a clean working tree, and otherwise throws
6528
+ * {@link SettingsBranchDivergedError}; local commits are never discarded.
6529
+ * An unreadable remote throws only for that empty branch, since one that
6530
+ * holds settings can serve them and the next save surfaces the remote error.
6531
+ * Either way a remote branch holding content is never adopted:
6532
+ * {@link SettingsBranchHasContentHistoryError}.
6064
6533
  */
6065
6534
  async createOrphanSettingsBranch(branchName, initialFiles) {
6066
6535
  try {
@@ -6070,56 +6539,709 @@ var GitManager = class _GitManager {
6070
6539
  }
6071
6540
  }
6072
6541
  async createOrphanSettingsBranchInner(branchName, initialFiles) {
6073
- log5.debug("git", "Creating orphan settings branch", { branchName });
6542
+ log6.debug("git", "Ensuring settings branch", { branchName });
6074
6543
  const branches = await this.git.branch();
6075
- if (branches.all.includes(branchName)) {
6076
- log5.debug("git", "Orphan branch already exists", { branchName });
6544
+ const localExists = branches.all.includes(branchName);
6545
+ if (localExists) {
6077
6546
  await this.git.checkout(branchName);
6547
+ }
6548
+ let remoteTip;
6549
+ try {
6550
+ remoteTip = await this.remoteBranchTip(branchName);
6551
+ } catch (err) {
6552
+ if (localExists && !await this.isEmptyInitialBranch(branchName)) {
6553
+ log6.debug("git", "Remote unreadable; serving the local settings branch", { branchName });
6554
+ return;
6555
+ }
6556
+ throw new SettingsRemoteUnreadableError(branchName, this.remote, err);
6557
+ }
6558
+ if (localExists) {
6559
+ await this.reconcileLocalSettingsBranch(branchName, remoteTip);
6560
+ return;
6561
+ }
6562
+ if (remoteTip) {
6563
+ const fetchedTip = await this.fetchBranchTip(branchName);
6564
+ await this.assertNotContentHistory(branchName, fetchedTip);
6565
+ await this.git.raw(["checkout", "-b", branchName, fetchedTip]);
6566
+ log6.debug("git", "Checked out the remote settings branch", { branchName });
6078
6567
  return;
6079
6568
  }
6080
6569
  await this.git.raw(["checkout", "--orphan", branchName]);
6081
- try {
6082
- await this.git.raw(["rm", "-rf", "."]);
6083
- } catch {
6570
+ await this.git.raw(["rm", "-r", "-f", "-q", "--sparse", "--ignore-unmatch", "--", "."]);
6571
+ const leftover = (await this.git.raw(["ls-files"])).trim();
6572
+ if (leftover) {
6573
+ 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
6574
  }
6085
6575
  for (const [filePath, content] of Object.entries(initialFiles)) {
6086
6576
  const absolutePath = path17.join(this.repoPath, filePath);
6087
- await fs16.mkdir(path17.dirname(absolutePath), { recursive: true });
6088
- await fs16.writeFile(absolutePath, content, "utf-8");
6577
+ await fs15.mkdir(path17.dirname(absolutePath), { recursive: true });
6578
+ await fs15.writeFile(absolutePath, content, "utf-8");
6089
6579
  await this.git.add(filePath);
6090
6580
  }
6091
6581
  await this.git.commit("Initialize settings branch", ["--allow-empty"]);
6092
- log5.debug("git", "Orphan settings branch created", { branchName });
6582
+ log6.debug("git", "Orphan settings branch created", { branchName });
6583
+ }
6584
+ /**
6585
+ * Compares the checked-out local settings branch with the remote's tip.
6586
+ * Related histories are left for the next settings pull to reconcile; only
6587
+ * an unrelated one is acted on here.
6588
+ */
6589
+ async reconcileLocalSettingsBranch(branchName, remoteTip) {
6590
+ if (!remoteTip)
6591
+ return;
6592
+ const localTip = (await this.git.revparse([`refs/heads/${branchName}`])).trim();
6593
+ if (localTip === remoteTip)
6594
+ return;
6595
+ const fetchedTip = await this.fetchBranchTip(branchName);
6596
+ const [localRoots, remoteRoots] = await Promise.all([
6597
+ this.rootCommits(localTip),
6598
+ this.rootCommits(fetchedTip)
6599
+ ]);
6600
+ if (localRoots.some((root) => remoteRoots.includes(root)))
6601
+ return;
6602
+ const localWork = [];
6603
+ if (!await this.isEmptyInitialBranch(branchName)) {
6604
+ localWork.push("commits beyond its empty initial commit");
6605
+ }
6606
+ const uncommitted = await this.getUncommittedFiles();
6607
+ if (uncommitted.length > 0) {
6608
+ localWork.push(`uncommitted changes to ${uncommitted.join(", ")}`);
6609
+ }
6610
+ if (localWork.length > 0) {
6611
+ throw new SettingsBranchDivergedError(branchName, this.repoPath, localWork.join(" and "));
6612
+ }
6613
+ await this.assertNotContentHistory(branchName, fetchedTip);
6614
+ await this.git.raw(["checkout", "-B", branchName, fetchedTip]);
6615
+ log6.debug("git", "Replaced an empty settings branch with the remote one", { branchName });
6616
+ }
6617
+ /**
6618
+ * The remote's tip for `branch`, or `undefined` when the remote has no such
6619
+ * branch. A remote that cannot be read throws: `ls-remote` exits 0 with no
6620
+ * output only for a reachable remote without the ref.
6621
+ */
6622
+ async remoteBranchTip(branch) {
6623
+ const ref = `refs/heads/${branch}`;
6624
+ const output = await this.git.raw(["ls-remote", "--heads", this.remote, ref]);
6625
+ for (const line of output.split("\n")) {
6626
+ const [sha, name] = line.trim().split(" ");
6627
+ if (sha && name === ref)
6628
+ return sha;
6629
+ }
6630
+ return void 0;
6631
+ }
6632
+ /**
6633
+ * Fetch `branch` from the remote and return the fetched commit, pinned as
6634
+ * pullBaseInner explains. The full ref: a bare name resolves to a
6635
+ * same-named tag first.
6636
+ */
6637
+ async fetchBranchTip(branch) {
6638
+ await this.git.fetch(this.remote, `refs/heads/${branch}`);
6639
+ return (await this.git.revparse(["FETCH_HEAD"])).trim();
6640
+ }
6641
+ /**
6642
+ * Refuse to adopt a remote settings branch that holds content: its tip has a file other than
6643
+ * the settings files, or its roots include the base branch's (a settings branch is an orphan).
6644
+ * Neither alone suffices: a base can have a root of its own, and a content tree can be pruned.
6645
+ * The base comes from the remote when the clone lacks it (it was cloned at an earlier base).
6646
+ */
6647
+ async assertNotContentHistory(branchName, tip) {
6648
+ const tree = await this.git.raw(["ls-tree", "-r", "--name-only", tip]);
6649
+ const strays = tree.split("\n").filter((file) => file && !SETTINGS_BRANCH_FILES.has(file));
6650
+ if (strays.length > 0) {
6651
+ const shown = strays.slice(0, 3).join(", ") + (strays.length > 3 ? ", \u2026" : "");
6652
+ throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it holds ${shown}`);
6653
+ }
6654
+ const [tipRoots, baseRoots] = await Promise.all([
6655
+ this.rootCommits(tip),
6656
+ this.baseBranchRev(branchName).then((rev) => this.rootCommits(rev))
6657
+ ]);
6658
+ if (tipRoots.some((root) => baseRoots.includes(root))) {
6659
+ throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it shares history with the base branch '${this.baseBranch}'`);
6660
+ }
6661
+ }
6662
+ async baseBranchRev(settingsBranch) {
6663
+ for (const ref of [
6664
+ `refs/heads/${this.baseBranch}`,
6665
+ `refs/remotes/${this.remote}/${this.baseBranch}`
6666
+ ]) {
6667
+ const sha = await this.git.raw(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).catch(() => "");
6668
+ if (sha.trim())
6669
+ return sha.trim();
6670
+ }
6671
+ try {
6672
+ return await this.fetchBranchTip(this.baseBranch);
6673
+ } catch (err) {
6674
+ 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)}`);
6675
+ }
6676
+ }
6677
+ async rootCommits(rev) {
6678
+ const output = await this.git.raw(["rev-list", "--max-parents=0", rev]);
6679
+ return output.split("\n").filter(Boolean);
6680
+ }
6681
+ /**
6682
+ * Whether `branch` is a single parentless commit with an empty tree, as
6683
+ * orphan creation leaves it. Full refs throughout: a bare name resolves to a
6684
+ * same-named tag first, and clones fetch tags.
6685
+ */
6686
+ async isEmptyInitialBranch(branch) {
6687
+ const ref = `refs/heads/${branch}`;
6688
+ const commits = await this.git.raw(["rev-list", "--max-count=2", ref]);
6689
+ if (commits.split("\n").filter(Boolean).length !== 1)
6690
+ return false;
6691
+ const tree = await this.git.raw(["ls-tree", ref]);
6692
+ return tree.trim() === "";
6693
+ }
6694
+ };
6695
+
6696
+ // dist/branch-provisioning.js
6697
+ init_logger();
6698
+ var PUBLISH_LOCK_WAIT_MS = 8e3;
6699
+ var CREATE_RESIDUE_MIN_QUIET_MS = 6e4;
6700
+ var STAGING_MAX_AGE_MS = 20 * 6e4;
6701
+ var REPAIR_MAX_AGE_MS = 20 * 6e4;
6702
+ var DELETING_MAX_AGE_MS = 10 * 6e4;
6703
+ var SETTINGS_TRASH_MAX_AGE_MS = 30 * 24 * 60 * 6e4;
6704
+ var BranchProvisioningBusyError = class extends Error {
6705
+ constructor(dirName, message = `Branch '${dirName}' is still being set up. Try again in a minute.`) {
6706
+ super(message);
6707
+ this.dirName = dirName;
6708
+ this.name = "BranchProvisioningBusyError";
6709
+ }
6710
+ };
6711
+ var BranchDirOccupiedError = class extends Error {
6712
+ constructor(dirName) {
6713
+ 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.`);
6714
+ this.dirName = dirName;
6715
+ this.name = "BranchDirOccupiedError";
6716
+ }
6717
+ };
6718
+ function formatDirStamp(date) {
6719
+ return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
6720
+ }
6721
+ function shortDirName(dirName) {
6722
+ const digest = createHash("sha1").update(dirName).digest("hex").slice(0, 10);
6723
+ return `${dirName.slice(0, 40)}-${digest}`;
6724
+ }
6725
+ function stampedName(prefix, dirName, now) {
6726
+ return `${prefix}${shortDirName(dirName)}-${randomBytes(3).toString("hex")}-${formatDirStamp(now)}`;
6727
+ }
6728
+ var STAGING_PREFIX = ".prov-";
6729
+ var REPAIR_PREFIX = ".repair-";
6730
+ var TRASH_PREFIX = ".trash-";
6731
+ var stagingDirName = (dirName, now = /* @__PURE__ */ new Date()) => stampedName(STAGING_PREFIX, dirName, now);
6732
+ var NO_GIT = { hasGitDir: false, configLock: false, hasIndex: false };
6733
+ async function listDir(dirPath) {
6734
+ try {
6735
+ return await fs16.readdir(dirPath);
6736
+ } catch (err) {
6737
+ if (isNodeError(err) && (err.code === "ENOENT" || err.code === "ENOTDIR"))
6738
+ return null;
6739
+ throw err;
6740
+ }
6741
+ }
6742
+ function gitConfigRemoteUrls(text) {
6743
+ const urls = [];
6744
+ let inRemote = false;
6745
+ for (const raw of text.split("\n")) {
6746
+ const line = raw.trim();
6747
+ if (line.startsWith("[")) {
6748
+ inRemote = /^\[\s*remote\s+"[^"]*"\s*\]/i.test(line);
6749
+ continue;
6750
+ }
6751
+ const match = inRemote ? /^url\s*=\s*(.*)$/i.exec(line) : null;
6752
+ if (match)
6753
+ urls.push(match[1].trim().replace(/^"(.*)"$/, "$1"));
6754
+ }
6755
+ return urls;
6756
+ }
6757
+ function sameRemote(url, expected) {
6758
+ if (isNetworkRemoteUrl(url) || isNetworkRemoteUrl(expected))
6759
+ return url === expected;
6760
+ const local = (value) => path18.resolve(value.replace(/^file:\/\//i, ""));
6761
+ return local(url) === local(expected);
6762
+ }
6763
+ async function classifyFinalDir(dirPath, expectedRemoteUrl) {
6764
+ let stat;
6765
+ try {
6766
+ stat = await fs16.lstat(dirPath);
6767
+ } catch (err) {
6768
+ if (isNotFoundError(err))
6769
+ return { kind: "vacant" };
6770
+ throw err;
6771
+ }
6772
+ if (!stat.isDirectory())
6773
+ return { kind: "residue", signature: NO_GIT };
6774
+ const metaEntries = await listDir(path18.join(dirPath, BRANCH_META_DIR)) ?? [];
6775
+ if (metaEntries.includes(BRANCH_META_FILE)) {
6776
+ try {
6777
+ return await readBranchMetadataFile(dirPath) ? { kind: "live" } : { kind: "protected" };
6778
+ } catch (err) {
6779
+ if (err instanceof BranchMetadataCorruptError)
6780
+ return { kind: "corrupt" };
6781
+ throw err;
6782
+ }
6783
+ }
6784
+ if (metaEntries.some((name) => name.startsWith(`${BRANCH_META_FILE}.corrupt-`))) {
6785
+ return { kind: "protected" };
6786
+ }
6787
+ const gitPath = path18.join(dirPath, ".git");
6788
+ let gitStat;
6789
+ try {
6790
+ gitStat = await fs16.lstat(gitPath);
6791
+ } catch (err) {
6792
+ if (isNotFoundError(err))
6793
+ return { kind: "residue", signature: NO_GIT };
6794
+ throw err;
6795
+ }
6796
+ if (!gitStat.isDirectory())
6797
+ return { kind: "foreign" };
6798
+ const gitEntries = await listDir(gitPath) ?? [];
6799
+ const signature = {
6800
+ hasGitDir: true,
6801
+ configLock: gitEntries.includes("config.lock"),
6802
+ hasIndex: gitEntries.includes("index")
6803
+ };
6804
+ let config;
6805
+ try {
6806
+ config = await fs16.readFile(path18.join(gitPath, "config"), "utf8");
6807
+ } catch {
6808
+ return { kind: "residue", signature };
6809
+ }
6810
+ return gitConfigRemoteUrls(config).some((url) => sameRemote(url, expectedRemoteUrl)) ? { kind: "residue", signature } : { kind: "foreign" };
6811
+ }
6812
+ async function quietAgeMs(dirPath, now = Date.now()) {
6813
+ const mtimes = await Promise.all(["", ".git", BRANCH_META_DIR].map((sub) => fs16.lstat(path18.join(dirPath, sub)).then((stat) => stat.mtimeMs, () => 0)));
6814
+ return now - Math.max(...mtimes);
6815
+ }
6816
+ async function quarantineResidueAt(baseRoot, dirName, options) {
6817
+ const finalPath = path18.join(baseRoot, dirName);
6818
+ let inode;
6819
+ try {
6820
+ inode = (await fs16.lstat(finalPath)).ino;
6821
+ } catch (err) {
6822
+ if (isNotFoundError(err))
6823
+ return { kind: "vacant" };
6824
+ throw err;
6825
+ }
6826
+ const candidateName = stampedName(REPAIR_PREFIX, dirName, /* @__PURE__ */ new Date());
6827
+ const candidate = path18.join(baseRoot, candidateName);
6828
+ try {
6829
+ await fs16.rename(finalPath, candidate);
6830
+ } catch (err) {
6831
+ if (isNotFoundError(err))
6832
+ return { kind: "vacant" };
6833
+ throw err;
6834
+ }
6835
+ const moveBack = async (why) => {
6836
+ try {
6837
+ await fs16.rename(candidate, finalPath);
6838
+ } catch (err) {
6839
+ canopyLogError(`[canopy] Could not move '${dirName}' back from ${candidateName} after ${why}: ${getErrorMessage(err)}. The worker's sweep restores or trashes it.`);
6840
+ }
6841
+ };
6842
+ const { state, quiet, reason } = await judgeQuarantineCandidate(candidate, inode, options).catch(async (err) => {
6843
+ await moveBack(`failing to judge it (${getErrorMessage(err)})`);
6844
+ throw err;
6845
+ });
6846
+ if (reason === void 0 && state.kind === "residue") {
6847
+ const trashName = stampedName(TRASH_PREFIX, dirName, /* @__PURE__ */ new Date());
6848
+ await fs16.rename(candidate, path18.join(baseRoot, trashName));
6849
+ const { hasGitDir, configLock, hasIndex } = state.signature;
6850
+ canopyLogWarn(`[canopy] Quarantined unfinished branch directory '${dirName}' as ${trashName} (git=${hasGitDir} config.lock=${configLock} index=${hasIndex} quiet=${Math.round(quiet / 1e3)}s)`);
6851
+ return { kind: "quarantined", trashName };
6852
+ }
6853
+ await moveBack(`declining to quarantine it (${reason})`);
6854
+ return { kind: "kept", reason: reason ?? "replaced" };
6855
+ }
6856
+ async function judgeQuarantineCandidate(candidate, inode, options) {
6857
+ const state = await classifyFinalDir(candidate, options.expectedRemoteUrl);
6858
+ const moved = await fs16.lstat(candidate).catch(() => null);
6859
+ const quiet = await quietAgeMs(candidate, options.now);
6860
+ let reason;
6861
+ if (!moved || moved.ino !== inode)
6862
+ reason = "replaced";
6863
+ else if (state.kind === "vacant")
6864
+ reason = "replaced";
6865
+ else if (state.kind !== "residue")
6866
+ reason = state.kind;
6867
+ else if (quiet < options.minQuietMs)
6868
+ reason = "too-young";
6869
+ return { state, quiet, reason };
6870
+ }
6871
+ async function removeLeftoverDir(dirPath) {
6872
+ try {
6873
+ await fs16.rm(dirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
6874
+ } catch (err) {
6875
+ canopyLogWarn(`[canopy] Could not remove ${dirPath}; the worker sweeps it: ${getErrorMessage(err)}`);
6876
+ }
6877
+ }
6878
+ async function hasBranchMetadata(branchRoot) {
6879
+ return fs16.access(path18.join(branchRoot, BRANCH_META_DIR, BRANCH_META_FILE)).then(() => true, () => false);
6880
+ }
6881
+ async function stageBranchWorkspace(options) {
6882
+ const { provisionLog: log8 } = options;
6883
+ const stagingPath = path18.join(options.baseRoot, stagingDirName(options.dirName));
6884
+ try {
6885
+ await log8.step("clone", () => GitManager.cloneWorkspace(options.remoteUrl, stagingPath, options.baseBranch, {
6886
+ ...options.clone,
6887
+ noCheckout: true
6888
+ }));
6889
+ const git = new GitManager({
6890
+ repoPath: stagingPath,
6891
+ baseBranch: options.baseBranch,
6892
+ remote: options.clone.remoteName
6893
+ });
6894
+ const cone = options.sparseCone;
6895
+ if (cone)
6896
+ await log8.step("sparse", () => git.setSparseCone(cone));
6897
+ if (await hasBranchMetadata(path18.join(options.baseRoot, options.dirName))) {
6898
+ await removeLeftoverDir(stagingPath);
6899
+ return { kind: "exists" };
6900
+ }
6901
+ await log8.step("checkout", () => git.checkoutFreshClone(options.branchName));
6902
+ await log8.step("exclude", () => git.ensureGitExclude(options.gitExcludePattern));
6903
+ const written = await log8.step("metadata", () => writeOccJsonFile(path18.join(stagingPath, BRANCH_META_DIR, BRANCH_META_FILE), { ...options.metadata }, { expectedVersion: null, trailingNewline: true }));
6904
+ return {
6905
+ kind: "staged",
6906
+ stagingPath,
6907
+ meta: { ...options.metadata, version: written.version, writeId: written.writeId }
6908
+ };
6909
+ } catch (err) {
6910
+ await removeLeftoverDir(stagingPath);
6911
+ throw err;
6912
+ }
6913
+ }
6914
+ async function lockBranchName(baseRoot, dirName) {
6915
+ try {
6916
+ return await acquireProvisioningLockWithin(baseRoot, branchProvisioningLockName(dirName), PUBLISH_LOCK_WAIT_MS);
6917
+ } catch (err) {
6918
+ if (isNodeError(err) && err.code === "ELOCKED")
6919
+ throw new BranchProvisioningBusyError(dirName);
6920
+ throw err;
6921
+ }
6922
+ }
6923
+ async function inspectBlocking(finalPath) {
6924
+ const entries = await listDir(path18.join(finalPath, BRANCH_META_DIR)) ?? [];
6925
+ if (entries.includes(BRANCH_META_FILE))
6926
+ return "live";
6927
+ if (entries.some((name) => name.startsWith(`${BRANCH_META_FILE}.corrupt-`)))
6928
+ return "protected";
6929
+ return "residue";
6930
+ }
6931
+ var RENAME_BLOCKED = /* @__PURE__ */ new Set(["EEXIST", "ENOTEMPTY", "ENOTDIR"]);
6932
+ async function publishStaging(options) {
6933
+ const { baseRoot, dirName, stagingPath } = options;
6934
+ const finalPath = path18.join(baseRoot, dirName);
6935
+ const release = await lockBranchName(baseRoot, dirName);
6936
+ try {
6937
+ for (let attempt = 0; attempt < 2; attempt++) {
6938
+ try {
6939
+ await fs16.rename(stagingPath, finalPath);
6940
+ return "published";
6941
+ } catch (err) {
6942
+ if (!isNodeError(err))
6943
+ throw err;
6944
+ if (err.code === "ENOENT") {
6945
+ const meta = await readBranchMetadataFile(finalPath).catch(() => null);
6946
+ if (meta?.writeId !== void 0 && meta.writeId === options.writeId)
6947
+ return "published";
6948
+ throw err;
6949
+ }
6950
+ if (!RENAME_BLOCKED.has(err.code ?? ""))
6951
+ throw err;
6952
+ }
6953
+ const blocking = await (options.inspectBlocked ?? inspectBlocking)(finalPath);
6954
+ if (blocking === "live")
6955
+ return "exists";
6956
+ if (blocking === "protected")
6957
+ throw new BranchProvisioningBusyError(dirName);
6958
+ const result = await quarantineResidueAt(baseRoot, dirName, {
6959
+ minQuietMs: CREATE_RESIDUE_MIN_QUIET_MS,
6960
+ expectedRemoteUrl: options.expectedRemoteUrl
6961
+ });
6962
+ if (result.kind !== "kept")
6963
+ continue;
6964
+ if (result.reason === "live")
6965
+ return "exists";
6966
+ if (result.reason === "corrupt") {
6967
+ await readBranchMetadataFile(finalPath);
6968
+ return "exists";
6969
+ }
6970
+ if (result.reason === "foreign")
6971
+ throw new BranchDirOccupiedError(dirName);
6972
+ throw new BranchProvisioningBusyError(dirName);
6973
+ }
6974
+ throw new BranchProvisioningBusyError(dirName);
6975
+ } finally {
6976
+ await release().catch((err) => {
6977
+ canopyLogWarn(`[canopy] Failed to release the provisioning lock for ${dirName}:`, err);
6978
+ });
6979
+ }
6980
+ }
6981
+
6982
+ // dist/branch-sparse.js
6983
+ import fs17 from "node:fs/promises";
6984
+ import path19 from "node:path";
6985
+ init_operating_mode();
6986
+ init_logger();
6987
+ function sparseConeFor(contentRoot) {
6988
+ const root = (contentRoot ?? "content").split("/").filter((segment) => segment && segment !== ".").join("/");
6989
+ return root ? [.../* @__PURE__ */ new Set([CANOPY_META_DIR, root])].sort() : null;
6990
+ }
6991
+ function sameCone(a, b) {
6992
+ if (a === null || b === null)
6993
+ return a === b;
6994
+ const [left, right] = [[...a].sort(), [...b].sort()];
6995
+ return left.length === right.length && left.every((dir, i) => dir === right[i]);
6996
+ }
6997
+ var SPARSE_CONE_RECORD = ".sparse-cone.json";
6998
+ function isConeDir(dir) {
6999
+ return typeof dir === "string" && dir.length > 0 && !path19.isAbsolute(dir) && !dir.split("/").some((segment) => segment === "" || segment === "." || segment === "..");
7000
+ }
7001
+ async function readRecordedSparseCone(contentBranchesRoot) {
7002
+ let raw;
7003
+ try {
7004
+ raw = await fs17.readFile(path19.join(contentBranchesRoot, SPARSE_CONE_RECORD), "utf8");
7005
+ } catch (err) {
7006
+ if (isNodeError(err) && err.code === "ENOENT")
7007
+ return void 0;
7008
+ throw err;
7009
+ }
7010
+ let parsed;
7011
+ try {
7012
+ parsed = JSON.parse(raw);
7013
+ } catch {
7014
+ return void 0;
7015
+ }
7016
+ if (typeof parsed !== "object" || parsed === null || !("cone" in parsed))
7017
+ return void 0;
7018
+ const { cone } = parsed;
7019
+ if (cone === null)
7020
+ return { cone: null };
7021
+ return Array.isArray(cone) && cone.length > 0 && cone.every(isConeDir) ? { cone: cone.filter(isConeDir) } : void 0;
7022
+ }
7023
+ async function recordSparseCone(contentBranchesRoot, cone) {
7024
+ const current = await readRecordedSparseCone(contentBranchesRoot);
7025
+ if (current && sameCone(current.cone, cone))
7026
+ return;
7027
+ const target = path19.join(contentBranchesRoot, SPARSE_CONE_RECORD);
7028
+ const temp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
7029
+ try {
7030
+ await fs17.writeFile(temp, `${JSON.stringify({ cone })}
7031
+ `, "utf8");
7032
+ } catch (err) {
7033
+ if (isNodeError(err) && err.code === "ENOENT")
7034
+ return;
7035
+ throw err;
7036
+ }
7037
+ try {
7038
+ await fs17.rename(temp, target);
7039
+ } catch (err) {
7040
+ await fs17.unlink(temp).catch(() => {
7041
+ });
7042
+ throw err;
7043
+ }
7044
+ }
7045
+
7046
+ // dist/branch-workspace.js
7047
+ init_operating_mode();
7048
+ init_logger();
7049
+
7050
+ // dist/utils/provision-log.js
7051
+ init_logger();
7052
+ import { randomBytes as randomBytes2 } from "node:crypto";
7053
+ var defaultSink = (line) => canopyLog(line);
7054
+ var sink = defaultSink;
7055
+ var ProvisionLog = class {
7056
+ constructor(dir) {
7057
+ this.id = randomBytes2(3).toString("hex");
7058
+ this.startedAt = Date.now();
7059
+ this.durations = [];
7060
+ this.prefix = `[canopy] provision id=${this.id} dir=${dir}`;
7061
+ }
7062
+ async step(name, run) {
7063
+ sink(`${this.prefix} step=${name} start`);
7064
+ const stepStartedAt = Date.now();
7065
+ let state = "failed";
7066
+ try {
7067
+ const result = await run();
7068
+ state = "done";
7069
+ return result;
7070
+ } finally {
7071
+ const ms = Date.now() - stepStartedAt;
7072
+ this.durations.push(`${name}=${ms}`);
7073
+ sink(`${this.prefix} step=${name} ${state} ms=${ms}`);
7074
+ }
7075
+ }
7076
+ finish(outcome) {
7077
+ const total = Date.now() - this.startedAt;
7078
+ sink(`${this.prefix} outcome=${outcome} total=${total} ${this.durations.join(" ")}`.trim());
6093
7079
  }
6094
7080
  };
6095
7081
 
6096
7082
  // dist/branch-workspace.js
6097
- var log6 = createDebugLogger({ prefix: "BranchWorkspace" });
6098
- var workspaceInitLocks = /* @__PURE__ */ new Map();
7083
+ var testHooks = {};
7084
+ var provisionsInFlight = /* @__PURE__ */ new Map();
6099
7085
  var BranchWorkspaceManager = class {
6100
7086
  constructor(config) {
6101
7087
  this.config = config;
6102
7088
  }
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;
7089
+ /** The branch's context, provisioning it first when it does not exist. */
7090
+ async openOrCreateBranch(options) {
7091
+ return (await this.provisionBranch(options)).context;
7092
+ }
7093
+ /**
7094
+ * Provision a branch workspace, or report the one already there.
7095
+ *
7096
+ * @throws BranchProvisioningBusyError when another process is mid-way through this branch, or
7097
+ * what is at its path is too fresh to judge (retriable)
7098
+ * @throws BranchDirOccupiedError in prod, when its path holds a repository this deployment did
7099
+ * not create
7100
+ * @throws BranchMetadataCorruptError when its branch.json does not parse
7101
+ */
7102
+ async provisionBranch(options) {
7103
+ if (isSettingsBranchName(options.branchName, operatingStrategy(options.mode).getSettingsBranchName(this.config))) {
7104
+ throw new BranchPathError("Settings branches are not content branches");
7105
+ }
7106
+ const { branchRoot, baseRoot, branchName: safeName } = resolveBranchPath(options);
7107
+ const paths = {
7108
+ branchRoot,
7109
+ baseRoot,
7110
+ dirName: path20.basename(branchRoot),
7111
+ safeName
7112
+ };
7113
+ const existing = await readBranchMetadataFile(branchRoot);
7114
+ if (existing) {
7115
+ return { kind: "exists", context: { branch: existing.branch, branchRoot, baseRoot } };
7116
+ }
7117
+ const inFlight = provisionsInFlight.get(branchRoot);
7118
+ if (inFlight)
7119
+ return { kind: "exists", context: (await inFlight).context };
7120
+ const run = this.provisionUnshared(options, paths);
7121
+ provisionsInFlight.set(branchRoot, run);
7122
+ try {
7123
+ return await run;
7124
+ } finally {
7125
+ provisionsInFlight.delete(branchRoot);
7126
+ }
7127
+ }
7128
+ async provisionUnshared(options, paths) {
7129
+ const { branchRoot, baseRoot, dirName } = paths;
7130
+ await fs18.mkdir(baseRoot, { recursive: true });
7131
+ const baseBranch = await resolveBaseBranch({
7132
+ defaultBaseBranch: this.config.defaultBaseBranch,
7133
+ mode: options.mode,
7134
+ detectFrom: this.config.sourceRoot ? path20.resolve(process.cwd(), this.config.sourceRoot) : void 0
7135
+ });
7136
+ const remoteUrl = await GitManager.resolveCloneRemoteUrl({
7137
+ mode: options.mode,
7138
+ remoteUrl: options.remoteUrl,
7139
+ defaultRemoteUrl: this.config.defaultRemoteUrl,
7140
+ baseBranch,
7141
+ sourceRoot: this.config.sourceRoot,
7142
+ allowNetworkRemoteInProd: this.config.allowNetworkRemoteInProd
7143
+ });
7144
+ const metadata = buildInitialBranchMetadata({
7145
+ branch: {
7146
+ name: paths.safeName,
7147
+ title: options.title,
7148
+ description: options.description,
7149
+ access: options.access,
7150
+ createdBy: options.createdBy,
7151
+ baseBranch
6109
7152
  }
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`);
7153
+ });
7154
+ const state = await classifyFinalDir(branchRoot, remoteUrl);
7155
+ if (state.kind === "live" || state.kind === "corrupt")
7156
+ return this.existing(paths);
7157
+ if (state.kind === "protected")
7158
+ throw new BranchProvisioningBusyError(dirName);
7159
+ if (state.kind === "foreign") {
7160
+ if (options.mode !== "dev")
7161
+ throw new BranchDirOccupiedError(dirName);
7162
+ return this.adoptInPlace(options, paths, baseBranch, metadata);
7163
+ }
7164
+ if (state.kind === "residue" && await this.recentlyActive(paths)) {
7165
+ throw new BranchProvisioningBusyError(dirName);
7166
+ }
7167
+ const sparseCone = sparseConeFor(this.config.contentRoot);
7168
+ await recordSparseCone(baseRoot, sparseCone).catch((err) => {
7169
+ canopyLogWarn(`[canopy] Could not record the sparse-checkout cone: ${getErrorMessage(err)}`);
7170
+ });
7171
+ const provisionLog = new ProvisionLog(dirName);
7172
+ let outcome;
7173
+ try {
7174
+ outcome = await this.stageAndPublish(paths, {
7175
+ provisionLog,
7176
+ baseBranch,
7177
+ remoteUrl,
7178
+ metadata,
7179
+ mode: options.mode,
7180
+ sparseCone
7181
+ });
7182
+ } catch (err) {
7183
+ provisionLog.finish("error");
7184
+ throw err;
7185
+ }
7186
+ provisionLog.finish(outcome.kind === "created" ? "ok" : "exists");
7187
+ return outcome;
7188
+ }
7189
+ async stageAndPublish(paths, run) {
7190
+ const { branchRoot, baseRoot, dirName } = paths;
7191
+ const staged = await stageBranchWorkspace({
7192
+ baseRoot,
7193
+ dirName,
7194
+ branchName: paths.safeName,
7195
+ baseBranch: run.baseBranch,
7196
+ remoteUrl: run.remoteUrl,
7197
+ clone: {
7198
+ remoteName: this.config.defaultRemoteName,
7199
+ config: managedWorkspaceConfig(this.config.gitBotAuthorName, this.config.gitBotAuthorEmail)
7200
+ },
7201
+ gitExcludePattern: operatingStrategy(run.mode).getGitExcludePattern(),
7202
+ metadata: run.metadata,
7203
+ provisionLog: run.provisionLog,
7204
+ sparseCone: run.sparseCone
7205
+ });
7206
+ if (staged.kind === "exists")
7207
+ return this.existing(paths);
7208
+ let published;
7209
+ try {
7210
+ await testHooks.beforePublish?.({ stagingPath: staged.stagingPath, finalPath: branchRoot });
7211
+ published = await run.provisionLog.step("publish", () => publishStaging({
7212
+ baseRoot,
7213
+ dirName,
7214
+ stagingPath: staged.stagingPath,
7215
+ writeId: staged.meta.writeId,
7216
+ expectedRemoteUrl: run.remoteUrl,
7217
+ inspectBlocked: testHooks.inspectBlocked
7218
+ }));
7219
+ } finally {
7220
+ if (published !== "published")
7221
+ await removeLeftoverDir(staged.stagingPath);
7222
+ }
7223
+ if (published === "exists")
7224
+ return this.existing(paths);
7225
+ await run.provisionLog.step("register", () => register(paths));
7226
+ return { kind: "created", context: { branch: staged.meta.branch, branchRoot, baseRoot } };
7227
+ }
7228
+ /**
7229
+ * The one exception to [PROV-1]: a dev workspace that already holds real work is kept where it
7230
+ * is, given branch.json under the branch's lock.
7231
+ */
7232
+ async adoptInPlace(options, paths, baseBranch, metadata) {
7233
+ const { branchRoot, baseRoot, dirName } = paths;
7234
+ const provisionLog = new ProvisionLog(dirName);
7235
+ let created = false;
7236
+ try {
7237
+ const release = await provisionLog.step("lock", () => lockBranchName(baseRoot, dirName));
7238
+ try {
7239
+ if (!await readBranchMetadataFile(branchRoot)) {
6118
7240
  await GitManager.initializeWorkspace({
6119
- workspacePath: options.branchRoot,
6120
- branchName: options.branchName,
7241
+ workspacePath: branchRoot,
7242
+ branchName: paths.safeName,
6121
7243
  mode: options.mode,
6122
- baseBranch: options.baseBranch,
7244
+ baseBranch,
6123
7245
  sourceRoot: this.config.sourceRoot,
6124
7246
  defaultRemoteUrl: this.config.defaultRemoteUrl,
6125
7247
  remoteUrl: options.remoteUrl,
@@ -6128,60 +7250,55 @@ var BranchWorkspaceManager = class {
6128
7250
  branchType: "content",
6129
7251
  gitBotAuthorName: this.config.gitBotAuthorName,
6130
7252
  gitBotAuthorEmail: this.config.gitBotAuthorEmail,
6131
- gitExcludePattern: operatingStrategy(options.mode).getGitExcludePattern()
7253
+ gitExcludePattern: operatingStrategy(options.mode).getGitExcludePattern(),
7254
+ provisionLog
6132
7255
  });
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);
7256
+ created = await provisionLog.step("metadata", () => writeOccJsonFile(path20.join(branchRoot, BRANCH_META_DIR, BRANCH_META_FILE), { ...metadata }, { expectedVersion: null, trailingNewline: true }).then(() => true, (err) => {
7257
+ if (err instanceof OccWriteConflictError)
7258
+ return false;
7259
+ throw err;
7260
+ }));
6142
7261
  }
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
7262
+ } finally {
7263
+ await release().catch(() => {
7264
+ });
6176
7265
  }
6177
- });
6178
- return {
6179
- branch: meta.branch,
6180
- branchRoot,
6181
- baseRoot
6182
- };
7266
+ if (created)
7267
+ await provisionLog.step("register", () => register(paths));
7268
+ } catch (err) {
7269
+ provisionLog.finish("error");
7270
+ throw err;
7271
+ }
7272
+ provisionLog.finish(created ? "ok" : "exists");
7273
+ if (!created)
7274
+ return this.existing(paths);
7275
+ const meta = await readBranchMetadataFile(branchRoot);
7276
+ if (!meta)
7277
+ throw new BranchProvisioningBusyError(dirName);
7278
+ return { kind: "created", context: { branch: meta.branch, branchRoot, baseRoot } };
7279
+ }
7280
+ /** Whether residue at the branch path may still belong to a live process. */
7281
+ async recentlyActive({ branchRoot, baseRoot, dirName }) {
7282
+ const lockAgeMs = await fs18.stat(path20.join(baseRoot, branchProvisioningLockName(dirName))).then((stat) => Date.now() - stat.mtimeMs, () => Infinity);
7283
+ if (lockAgeMs < PROVISIONING_LOCK_STALE_MS)
7284
+ return true;
7285
+ return await quietAgeMs(branchRoot) < CREATE_RESIDUE_MIN_QUIET_MS;
7286
+ }
7287
+ async existing({ branchRoot, baseRoot, dirName }) {
7288
+ const meta = await readBranchMetadataFile(branchRoot);
7289
+ if (!meta)
7290
+ throw new BranchProvisioningBusyError(dirName);
7291
+ return { kind: "exists", context: { branch: meta.branch, branchRoot, baseRoot } };
6183
7292
  }
6184
7293
  };
7294
+ async function register({ branchRoot, baseRoot }) {
7295
+ try {
7296
+ await invalidateBranchContentCaches(branchRoot);
7297
+ await new BranchRegistry(baseRoot).invalidate();
7298
+ } catch (err) {
7299
+ canopyLogWarn(`[canopy] Registering new branch ${branchRoot} failed: ${getErrorMessage(err)}`);
7300
+ }
7301
+ }
6185
7302
  async function loadOrCreateBranchContext(options) {
6186
7303
  if (readsFromCheckout(options.config)) {
6187
7304
  const cwd = process.cwd();
@@ -6239,8 +7356,8 @@ async function resolveBranchRoot(config) {
6239
7356
  }
6240
7357
 
6241
7358
  // dist/content-listing.js
6242
- import fs17 from "node:fs/promises";
6243
- import path19 from "node:path";
7359
+ import fs19 from "node:fs/promises";
7360
+ import path21 from "node:path";
6244
7361
  import matter3 from "gray-matter";
6245
7362
  import { parse as yamlParse2 } from "yaml";
6246
7363
  var log7 = createDebugLogger({ prefix: "ContentListing" });
@@ -6253,7 +7370,7 @@ var normalizePath = (root, target) => {
6253
7370
  };
6254
7371
  var readEntryData = async (filePath, format, bodyFieldName = "body") => {
6255
7372
  try {
6256
- const raw = await fs17.readFile(filePath, "utf8");
7373
+ const raw = await fs19.readFile(filePath, "utf8");
6257
7374
  if (format === "json") {
6258
7375
  return asRecord(JSON.parse(raw));
6259
7376
  }
@@ -6273,9 +7390,13 @@ var readEntryData = async (filePath, format, bodyFieldName = "body") => {
6273
7390
  return {};
6274
7391
  }
6275
7392
  };
6276
- var createReferenceResolver = (branchRoot, flatSchema, contentRootName) => ({
7393
+ var createReferenceResolver = (branchRoot, flatSchema, contentRootName, visibility) => ({
6277
7394
  store: new ContentStore(branchRoot, flatSchema, { contentRootName }),
6278
- cache: createReferenceResolveCache()
7395
+ cache: createReferenceResolveCache(),
7396
+ // The predicate that hides an entry from the listing is the one that restricts it as a
7397
+ // reference target, so a target is never shown in a sibling's data that its own listing
7398
+ // row would have hidden.
7399
+ access: visibility?.shouldInclude
6279
7400
  });
6280
7401
  var resolveCollectionItemReferences = async (items, collection, resolver) => Promise.all(items.map(async (item) => {
6281
7402
  const fields = collection.entries?.find((e) => e.name === item.entryType)?.schema;
@@ -6283,7 +7404,7 @@ var resolveCollectionItemReferences = async (items, collection, resolver) => Pro
6283
7404
  return item;
6284
7405
  return {
6285
7406
  ...item,
6286
- data: await resolver.store.resolveReferences(item.data, fields, resolver.cache)
7407
+ data: await resolver.store.resolveReferences(item.data, fields, resolver.cache, resolver.access)
6287
7408
  };
6288
7409
  }));
6289
7410
  async function listEntries(branchRoot, flatSchema, contentRootName, options, visibility) {
@@ -6294,10 +7415,10 @@ async function listEntries(branchRoot, flatSchema, contentRootName, options, vis
6294
7415
  const collections = flatSchema.filter((item) => item.type === "collection" && item.entries !== void 0 && (item.logicalPath === rootPath || item.logicalPath.startsWith(`${rootPath}/`)));
6295
7416
  const shouldInclude = visibility?.shouldInclude;
6296
7417
  const skippedFiles = [];
6297
- const resolver = options?.resolveReferences ? createReferenceResolver(branchRoot, flatSchema, contentRootName) : null;
7418
+ const resolver = options?.resolveReferences ? createReferenceResolver(branchRoot, flatSchema, contentRootName, visibility) : null;
6298
7419
  const collectionResults = await Promise.all(collections.map(async (collection) => {
6299
7420
  const entries = await listCollectionEntries(branchRoot, collection, (file) => skippedFiles.push(file));
6300
- const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.physicalPath)) : entries;
7421
+ const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.logicalPath)) : entries;
6301
7422
  const resolved = resolver ? await resolveCollectionItemReferences(visible, collection, resolver) : visible;
6302
7423
  return resolved.map((entry) => ({ entry, collection }));
6303
7424
  }));
@@ -6375,7 +7496,7 @@ var listCollectionEntries = async (root, collection, onSkip) => {
6375
7496
  normalizePath(root, collectionRoot);
6376
7497
  let dirents;
6377
7498
  try {
6378
- dirents = await fs17.readdir(collectionRoot, { withFileTypes: true });
7499
+ dirents = await fs19.readdir(collectionRoot, { withFileTypes: true });
6379
7500
  } catch (err) {
6380
7501
  if (isNotFoundError(err))
6381
7502
  return [];
@@ -6383,7 +7504,7 @@ var listCollectionEntries = async (root, collection, onSkip) => {
6383
7504
  }
6384
7505
  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
7506
  const entries = await Promise.all(files.map(async (file) => {
6386
- const absolutePath = path19.join(collectionRoot, file.name);
7507
+ const absolutePath = path21.join(collectionRoot, file.name);
6387
7508
  const relativePath = normalizePath(root, absolutePath);
6388
7509
  const parsed = parseTypedFilename(file.name, entryTypes);
6389
7510
  if (!parsed) {
@@ -6398,11 +7519,11 @@ var listCollectionEntries = async (root, collection, onSkip) => {
6398
7519
  const format = entryType?.format || "json";
6399
7520
  const bodyField = entryType?.schema ? findBodyFieldName(entryType.schema) : "body";
6400
7521
  const [stats, data] = await Promise.all([
6401
- fs17.stat(absolutePath),
7522
+ fs19.stat(absolutePath),
6402
7523
  readEntryData(absolutePath, format, bodyField)
6403
7524
  ]);
6404
7525
  const item = {
6405
- logicalPath: `${collection.logicalPath}/${slug}`,
7526
+ logicalPath: entryLogicalPath(collection.logicalPath, slug),
6406
7527
  contentId,
6407
7528
  slug,
6408
7529
  collectionPath: collection.logicalPath,
@@ -6422,7 +7543,8 @@ var listCollectionEntries = async (root, collection, onSkip) => {
6422
7543
  init_config2();
6423
7544
 
6424
7545
  // dist/assets/transform-directives.js
6425
- var MAX_INPUT_PIXELS = 4096 * 4096;
7546
+ var MAX_INPUT_PIXELS = 6e3 * 4e3;
7547
+ var CROP_PRECISION = 4;
6426
7548
  function isValidCropRect(x, y, w, h) {
6427
7549
  for (const n of [x, y, w, h]) {
6428
7550
  if (!Number.isFinite(n) || n < 0 || n > 1)
@@ -6434,6 +7556,21 @@ function isValidCropRect(x, y, w, h) {
6434
7556
  return false;
6435
7557
  return true;
6436
7558
  }
7559
+ function roundToCropPrecision(n) {
7560
+ const factor = 10 ** CROP_PRECISION;
7561
+ return Math.round(n * factor) / factor;
7562
+ }
7563
+ function roundCropRect(rect) {
7564
+ const x = roundToCropPrecision(rect.x);
7565
+ const y = roundToCropPrecision(rect.y);
7566
+ let w = roundToCropPrecision(rect.w);
7567
+ let h = roundToCropPrecision(rect.h);
7568
+ if (x + w > 1)
7569
+ w = roundToCropPrecision(1 - x);
7570
+ if (y + h > 1)
7571
+ h = roundToCropPrecision(1 - y);
7572
+ return { x, y, w, h };
7573
+ }
6437
7574
 
6438
7575
  // dist/validation/field-traversal.js
6439
7576
  function isPlainRecord2(value) {
@@ -6466,32 +7603,35 @@ function isValidImageCropValue(value) {
6466
7603
  if (typeof x !== "number" || typeof y !== "number" || typeof w !== "number" || typeof h !== "number") {
6467
7604
  return false;
6468
7605
  }
6469
- return isValidCropRect(x, y, w, h);
7606
+ if (!isValidCropRect(x, y, w, h))
7607
+ return false;
7608
+ const rounded = roundCropRect({ x, y, w, h });
7609
+ return isValidCropRect(rounded.x, rounded.y, rounded.w, rounded.h);
6470
7610
  }
6471
- function validateImageValue(field, value, path22) {
7611
+ function validateImageValue(field, value, path24) {
6472
7612
  if (!isPlainRecord3(value)) {
6473
- return [{ fieldPath: path22, message: "Expected an image object with { src, alt }" }];
7613
+ return [{ fieldPath: path24, message: "Expected an image object with { src, alt }" }];
6474
7614
  }
6475
7615
  const errors = [];
6476
7616
  if (typeof value.src !== "string" || value.src.trim() === "") {
6477
- errors.push({ fieldPath: `${path22}.src`, message: "Image src is required" });
7617
+ errors.push({ fieldPath: `${path24}.src`, message: "Image src is required" });
6478
7618
  }
6479
7619
  if (typeof value.alt !== "string") {
6480
- errors.push({ fieldPath: `${path22}.alt`, message: "Image alt text is required" });
7620
+ errors.push({ fieldPath: `${path24}.alt`, message: "Image alt text is required" });
6481
7621
  } else if (value.alt.trim() === "" && field.altOptional !== true) {
6482
- errors.push({ fieldPath: `${path22}.alt`, message: "Image alt text is required" });
7622
+ errors.push({ fieldPath: `${path24}.alt`, message: "Image alt text is required" });
6483
7623
  }
6484
7624
  if (value.width !== void 0 && !isPositiveInt(value.width)) {
6485
- errors.push({ fieldPath: `${path22}.width`, message: "Image width must be a positive integer" });
7625
+ errors.push({ fieldPath: `${path24}.width`, message: "Image width must be a positive integer" });
6486
7626
  }
6487
7627
  if (value.height !== void 0 && !isPositiveInt(value.height)) {
6488
7628
  errors.push({
6489
- fieldPath: `${path22}.height`,
7629
+ fieldPath: `${path24}.height`,
6490
7630
  message: "Image height must be a positive integer"
6491
7631
  });
6492
7632
  }
6493
7633
  if (value.crop !== void 0 && !isValidImageCropValue(value.crop)) {
6494
- errors.push({ fieldPath: `${path22}.crop`, message: "Invalid image crop rect" });
7634
+ errors.push({ fieldPath: `${path24}.crop`, message: "Invalid image crop rect" });
6495
7635
  }
6496
7636
  return errors;
6497
7637
  }
@@ -6503,44 +7643,44 @@ function referenceValueId(value) {
6503
7643
  return void 0;
6504
7644
  }
6505
7645
  var selectOptionValues = (field) => (field.options ?? []).map((opt) => typeof opt === "string" ? opt : opt.value);
6506
- function validateScalar(field, value, path22) {
7646
+ function validateScalar(field, value, path24) {
6507
7647
  switch (field.type) {
6508
7648
  case "number":
6509
7649
  if (typeof value !== "number" || !Number.isFinite(value)) {
6510
- return [{ fieldPath: path22, message: "Expected a number" }];
7650
+ return [{ fieldPath: path24, message: "Expected a number" }];
6511
7651
  }
6512
7652
  return [];
6513
7653
  case "boolean":
6514
7654
  if (typeof value !== "boolean") {
6515
- return [{ fieldPath: path22, message: "Expected true or false" }];
7655
+ return [{ fieldPath: path24, message: "Expected true or false" }];
6516
7656
  }
6517
7657
  return [];
6518
7658
  case "datetime":
6519
7659
  if (typeof value !== "string" || value !== "" && Number.isNaN(Date.parse(value))) {
6520
- return [{ fieldPath: path22, message: "Expected a valid date/time string" }];
7660
+ return [{ fieldPath: path24, message: "Expected a valid date/time string" }];
6521
7661
  }
6522
7662
  return [];
6523
7663
  case "select": {
6524
7664
  if (typeof value !== "string") {
6525
- return [{ fieldPath: path22, message: "Expected a selection" }];
7665
+ return [{ fieldPath: path24, message: "Expected a selection" }];
6526
7666
  }
6527
7667
  const options = selectOptionValues(field);
6528
7668
  if (value !== "" && !options.includes(value)) {
6529
- return [{ fieldPath: path22, message: `Must be one of: ${options.join(", ")}` }];
7669
+ return [{ fieldPath: path24, message: `Must be one of: ${options.join(", ")}` }];
6530
7670
  }
6531
7671
  return [];
6532
7672
  }
6533
7673
  case "reference":
6534
7674
  if (referenceValueId(value) === void 0) {
6535
- return [{ fieldPath: path22, message: "Expected a reference id" }];
7675
+ return [{ fieldPath: path24, message: "Expected a reference id" }];
6536
7676
  }
6537
7677
  return [];
6538
7678
  case "image":
6539
- return validateImageValue(field, value, path22);
7679
+ return validateImageValue(field, value, path24);
6540
7680
  default:
6541
7681
  if (STRING_FIELD_TYPES.has(field.type)) {
6542
7682
  if (typeof value !== "string") {
6543
- return [{ fieldPath: path22, message: "Expected text" }];
7683
+ return [{ fieldPath: path24, message: "Expected text" }];
6544
7684
  }
6545
7685
  }
6546
7686
  return [];
@@ -6563,12 +7703,12 @@ function validateEntryData(fields, data, pathPrefix = "") {
6563
7703
  errors.push(...validateEntryData(field.fields, data, pathPrefix));
6564
7704
  continue;
6565
7705
  }
6566
- const path22 = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
7706
+ const path24 = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
6567
7707
  const value = data[field.name];
6568
7708
  const isRequired = "required" in field && field.required === true;
6569
7709
  if (isRequired && isEmptyForRequired(field, value)) {
6570
7710
  const message = Array.isArray(value) ? "At least one item is required" : "This field is required";
6571
- errors.push({ fieldPath: path22, message });
7711
+ errors.push({ fieldPath: path24, message });
6572
7712
  continue;
6573
7713
  }
6574
7714
  if (value === void 0 || value === null)
@@ -6576,11 +7716,11 @@ function validateEntryData(fields, data, pathPrefix = "") {
6576
7716
  if (field.type === "block") {
6577
7717
  const blockField = field;
6578
7718
  if (!Array.isArray(value)) {
6579
- errors.push({ fieldPath: path22, message: "Expected a list of blocks" });
7719
+ errors.push({ fieldPath: path24, message: "Expected a list of blocks" });
6580
7720
  continue;
6581
7721
  }
6582
7722
  value.forEach((item, index) => {
6583
- const itemPath = `${path22}[${index}]`;
7723
+ const itemPath = `${path24}[${index}]`;
6584
7724
  if (!isPlainRecord3(item)) {
6585
7725
  errors.push({ fieldPath: itemPath, message: "Expected a block object" });
6586
7726
  return;
@@ -6602,11 +7742,11 @@ function validateEntryData(fields, data, pathPrefix = "") {
6602
7742
  const objectField = field;
6603
7743
  if (objectField.list) {
6604
7744
  if (!Array.isArray(value)) {
6605
- errors.push({ fieldPath: path22, message: "Expected a list of items" });
7745
+ errors.push({ fieldPath: path24, message: "Expected a list of items" });
6606
7746
  continue;
6607
7747
  }
6608
7748
  value.forEach((item, index) => {
6609
- const itemPath = `${path22}[${index}]`;
7749
+ const itemPath = `${path24}[${index}]`;
6610
7750
  if (!isPlainRecord3(item)) {
6611
7751
  errors.push({ fieldPath: itemPath, message: "Expected an object" });
6612
7752
  return;
@@ -6616,27 +7756,27 @@ function validateEntryData(fields, data, pathPrefix = "") {
6616
7756
  }
6617
7757
  });
6618
7758
  } else if (!isPlainRecord3(value)) {
6619
- errors.push({ fieldPath: path22, message: "Expected an object" });
7759
+ errors.push({ fieldPath: path24, message: "Expected an object" });
6620
7760
  } else if (objectField.fields) {
6621
- errors.push(...validateEntryData(objectField.fields, value, path22));
7761
+ errors.push(...validateEntryData(objectField.fields, value, path24));
6622
7762
  }
6623
7763
  continue;
6624
7764
  }
6625
7765
  if ("list" in field && field.list) {
6626
7766
  if (!Array.isArray(value)) {
6627
- errors.push({ fieldPath: path22, message: "Expected a list of values" });
7767
+ errors.push({ fieldPath: path24, message: "Expected a list of values" });
6628
7768
  continue;
6629
7769
  }
6630
7770
  value.forEach((item, index) => {
6631
- errors.push(...validateScalar(field, item, `${path22}[${index}]`));
7771
+ errors.push(...validateScalar(field, item, `${path24}[${index}]`));
6632
7772
  });
6633
7773
  continue;
6634
7774
  }
6635
7775
  if (Array.isArray(value) && KNOWN_FIELD_TYPES.has(field.type)) {
6636
- errors.push({ fieldPath: path22, message: "Expected a single value, not a list" });
7776
+ errors.push({ fieldPath: path24, message: "Expected a single value, not a list" });
6637
7777
  continue;
6638
7778
  }
6639
- errors.push(...validateScalar(field, value, path22));
7779
+ errors.push(...validateScalar(field, value, path24));
6640
7780
  }
6641
7781
  return errors;
6642
7782
  }
@@ -6687,7 +7827,7 @@ These are likely abandoned create-scaffolds (empty entries left behind when a cr
6687
7827
  var GENERATED_RECORD_FILENAME = ".canopy-generated.json";
6688
7828
  async function readGeneratedRecord(outputDir) {
6689
7829
  try {
6690
- const raw = await fs18.readFile(path20.join(outputDir, GENERATED_RECORD_FILENAME), "utf-8");
7830
+ const raw = await fs20.readFile(path22.join(outputDir, GENERATED_RECORD_FILENAME), "utf-8");
6691
7831
  const parsed = JSON.parse(raw);
6692
7832
  if (typeof parsed !== "object" || parsed === null || !("files" in parsed))
6693
7833
  return [];
@@ -6701,19 +7841,19 @@ async function readGeneratedRecord(outputDir) {
6701
7841
  }
6702
7842
  async function writeGeneratedRecord(outputDir, files) {
6703
7843
  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)}
7844
+ await fs20.mkdir(outputDir, { recursive: true });
7845
+ await fs20.writeFile(path22.join(outputDir, GENERATED_RECORD_FILENAME), `${JSON.stringify(record, null, 2)}
6706
7846
  `, "utf-8");
6707
7847
  }
6708
7848
  async function removeEmptyDirsUpward(startDir, stopAt) {
6709
7849
  let current = startDir;
6710
7850
  while (current.startsWith(stopAt) && current !== stopAt) {
6711
7851
  try {
6712
- await fs18.rmdir(current);
7852
+ await fs20.rmdir(current);
6713
7853
  } catch {
6714
7854
  return;
6715
7855
  }
6716
- current = path20.dirname(current);
7856
+ current = path22.dirname(current);
6717
7857
  }
6718
7858
  }
6719
7859
  var SAFE_BUILD_ID = /^[A-Za-z0-9._-]{1,255}$/;
@@ -6775,9 +7915,9 @@ async function generateAIContentFiles(options) {
6775
7915
  entryLinkUrl: config.entryLinkUrl,
6776
7916
  ...resolveBuildStamp()
6777
7917
  });
6778
- const absoluteOutputDir = path20.resolve(outputDir) + path20.sep;
7918
+ const absoluteOutputDir = path22.resolve(outputDir) + path22.sep;
6779
7919
  const resolveOutputPath = (filePath) => {
6780
- const absolutePath = path20.resolve(path20.join(absoluteOutputDir, filePath));
7920
+ const absolutePath = path22.resolve(path22.join(absoluteOutputDir, filePath));
6781
7921
  if (!absolutePath.startsWith(absoluteOutputDir)) {
6782
7922
  throw new Error(`Path traversal detected in AI content output: ${filePath}`);
6783
7923
  }
@@ -6789,8 +7929,8 @@ async function generateAIContentFiles(options) {
6789
7929
  let fileCount = 0;
6790
7930
  for (const [filePath, content] of result.files) {
6791
7931
  const absolutePath = resolveOutputPath(filePath);
6792
- await fs18.mkdir(path20.dirname(absolutePath), { recursive: true });
6793
- await fs18.writeFile(absolutePath, content, "utf-8");
7932
+ await fs20.mkdir(path22.dirname(absolutePath), { recursive: true });
7933
+ await fs20.writeFile(absolutePath, content, "utf-8");
6794
7934
  fileCount++;
6795
7935
  }
6796
7936
  const currentSet = new Set(currentFiles);
@@ -6799,13 +7939,13 @@ async function generateAIContentFiles(options) {
6799
7939
  for (const filePath of stale) {
6800
7940
  const absolutePath = resolveOutputPath(filePath);
6801
7941
  try {
6802
- await fs18.unlink(absolutePath);
7942
+ await fs20.unlink(absolutePath);
6803
7943
  removedCount++;
6804
7944
  } catch (err) {
6805
7945
  if (!isNodeError(err) || err.code !== "ENOENT")
6806
7946
  throw err;
6807
7947
  }
6808
- await removeEmptyDirsUpward(path20.dirname(absolutePath), absoluteOutputDir);
7948
+ await removeEmptyDirsUpward(path22.dirname(absolutePath), absoluteOutputDir);
6809
7949
  }
6810
7950
  await writeGeneratedRecord(absoluteOutputDir, currentFiles);
6811
7951
  return { fileCount, removedCount, outputDir: absoluteOutputDir };
@@ -6816,7 +7956,7 @@ var jiti = createJiti(import.meta.url);
6816
7956
  async function generateAIContentCLI(options) {
6817
7957
  const { projectDir, outputDir = "public/ai", configPath, appDir = "app" } = options;
6818
7958
  console.log("\nCanopyCMS generate-ai-content\n");
6819
- const canopyConfigPath = path21.join(projectDir, "canopycms.config.ts");
7959
+ const canopyConfigPath = path23.join(projectDir, "canopycms.config.ts");
6820
7960
  let canopyConfigModule;
6821
7961
  try {
6822
7962
  canopyConfigModule = await jiti.import(canopyConfigPath);
@@ -6827,7 +7967,7 @@ async function generateAIContentCLI(options) {
6827
7967
  }
6828
7968
  const configExport = canopyConfigModule.default ?? canopyConfigModule.config ?? canopyConfigModule;
6829
7969
  const serverConfig = typeof configExport === "object" && configExport !== null && "server" in configExport ? configExport.server : configExport;
6830
- const schemasPath = path21.join(projectDir, appDir, "schemas.ts");
7970
+ const schemasPath = path23.join(projectDir, appDir, "schemas.ts");
6831
7971
  let entrySchemaRegistry = {};
6832
7972
  try {
6833
7973
  const schemasModule = await jiti.import(schemasPath);
@@ -6838,7 +7978,7 @@ async function generateAIContentCLI(options) {
6838
7978
  let aiConfig;
6839
7979
  if (configPath) {
6840
7980
  try {
6841
- const aiConfigModule = await jiti.import(path21.resolve(configPath));
7981
+ const aiConfigModule = await jiti.import(path23.resolve(configPath));
6842
7982
  aiConfig = aiConfigModule.aiContentConfig ?? aiConfigModule.default ?? aiConfigModule.config;
6843
7983
  } catch (err) {
6844
7984
  console.error(`Could not load AI config from ${configPath}`);
@@ -6855,7 +7995,7 @@ async function generateAIContentCLI(options) {
6855
7995
  console.error("Make sure canopycms.config.ts uses defineCanopyConfig().");
6856
7996
  process.exit(1);
6857
7997
  }
6858
- const resolvedOutput = path21.resolve(projectDir, outputDir);
7998
+ const resolvedOutput = path23.resolve(projectDir, outputDir);
6859
7999
  console.log(` Output: ${resolvedOutput}`);
6860
8000
  console.log(` Mode: ${serverConfig.mode ?? "dev"}`);
6861
8001
  const result = await generateAIContentFiles({